简述一下JVM
虚拟机概念:
虚拟机是模拟计算机执行虚拟计算机指令的软件,分为系统虚拟机[Virtual Box、VMware提供操作系统,对物理计算机仿真]和程序虚拟机[JVM执行Java字节码指令]
JVM的工作原理是装载二进制字节码,把二进制字节码解释编译为运行平台的机器指令交给底层硬件执行
二进制码只是一个跨平台的通用契约,内部包含的仅仅是一些能被JVM识别的字节码指令、符号表以及其他辅助信息;操作系统只能识别机器指令或者汇编指令;需要将字节码指令翻译成机器指令操作系统才能识别执行
虚拟机是相对于物理机的概念,虚拟机和物理机都有代码执行能力,区别是物理机的执行引擎直接建立在处理器、缓存、指令集和操作系统层面上,虚拟机的执行引擎是由软件自行实现的,虚拟机可以不受物理条件的制约限制指令集与执行引擎的结构体系;物理机的指令集都是和物理硬件深度绑定,比如X86架构和ARM架构的指令集千差万别;虚拟机能在不同硬件平台上执行同一套指令集;但是执行效率相较于物理机略差一些
JVM特点:
一次编译,处处运行;自动内存管理;自动垃圾回收机制
Java虚拟机是对JVM规范的实现,oracle发布JVM规范,提供HotSpot作为openJDK和oracleJDK的默认虚拟机,不同厂商针对JVM规范有各自的虚拟机实现
JVM是一种跨语言平台,JVM的执行基础是字节码文件,JDK7以后JVM通过JSR292规范实现字节码文件可以由任意语言通过编译器编译获得,只要编译后的字节码文件遵循JVM规范就可以被JVM识别、装载和运行;该特点让JVM具备在大型平台上实现多语言混合编程解决特定领域问题的能力
使用基于栈的指令集架构
主流虚拟机都采用解释器和JIT即时编译器混合工作模式
JVM的生命周期
启动
JVM的启动通过类加载开始
执行
Java程序实际上就是JVM进程,JVM将编译后的字节码解释为机器指令通过操作系统交给计算机硬件执行,使用jps命令能查看当前计算机上执行的所有Java进程
退出
程序正常执行结束、程序执行过程中遇到异常或者错误异常终止、操作系统错误会导致虚拟机的退出
进程中的某个线程调用Runtime类或者System类的exit方法或者Runtime的halt方法,且Java安全管理器允许的情况下,JVM进程也会终止
调用JNI的API加载或者卸载JVM时,JVM进程也会终止
常见JVM
Classic VM:1996年由Sun公司随着JDK1.0发布的世界上第一款商用JVM
执行引擎中没有即时编译器,效率低下
可以外挂JIT即时编译器,但是外挂JIT即时编译器无法使用解释器,虽然执行快,但是JVM启动时会编译大量代码造成JVM启动时长时间卡顿
不能识别内存中的数据类型,通过句柄额外记录对象的内存地址来查找对象,不知道数据类型会带来一些麻烦,比如标记整理算法让JVM中的内存更紧凑会移动对象的位置,如果不知道内存存放的是数据本身还是引用会比较麻烦
Exact VM:JDK1.2由SUN公司发布
提供准确式内存管理,虚拟机可以知道指定内存中的数据类型
实现了解释器和即时编译器的混合工作
还没投产就被HotSpot替换
HotSpot VM:HotSpot是一家名为Longview Technologies的小公司设计,97年被SUN公司收购,JDK1.3到现在一直是默认虚拟机
HotSpot的名字就指的是它的热点代码探测技术,占有绝对的市场地位,不管是openJDK还是oracleJDK都用的是HotSpot虚拟机
方法区是HotSpot独有的,像J9、JRockit都没有方法区
JRockit VM:BEA公司发布,后被Oracle收购
JRockit VM专注于服务器端应用,不关注程序启动速度,关注程序的响应时间,因此JRockit VM内部没有解释器,全部代码都靠即时编译器编译;大量行业基准测试显示JRockit虚拟机是世界上最快的JVM,没有之一;提供毫秒甚至微秒级的响应,在延迟敏感型场景应用广泛
JRockit的Mission Control套件比较有用,被Oracle于JDK8整合到HotSpot中形成了现在的JMC,具体分成了内存泄漏检测器、JVM的运行时分析器和管理控制台三个独立应用程序,JMC的主要功能就是监控JVM的内存泄漏;
J9 VM:IBM发布
市场定位和HotSpot接近,作为服务端、桌面、嵌入式等多用途VM,广泛用于IBM的各种Java产品
在IBM自家产品上测试速度世界最快,但是通用性和其他产品上的性能比不上JRockit,而且在windows场景下使用Bug很多
2017年,IBM开源J9 VM命名为OpenJ9,交给Eclipse基金会管理
CDC/CLDC:oracle在Java ME方向发布的两款虚拟机
诺基亚时代的塞班系统,游戏和应用程序就是用Java ME产品线开发,现在手机被Android和IOS二分天下,Java ME几乎已经失去移动端市场、CLDC的KVM产品因为简单、轻量和高度可移植在更低端设备比如智能控制器、传感器和老年机上还在使用
TaobaoJVM:由AliJVM团队基于HotSpot深度定制开源的服务器版JVM
使用GCIH技术将生命周期较长的Java对象从堆空间移到堆外,降低了GC的频率并且实现GCIH中的对象在多个JVM进程中共享
使用crc32指令降低对JNI的调用开销
提供针对大数据场景的ZenGC
Taobao JVM在阿里产品上性能高,在硬件上严重依赖Intel的CPU,损失兼容性,淘宝、天猫的产品全都把Oracle的JVM替换成了Taobao JVM
Dalvik VM:由谷歌发布应用于Android系统的虚拟机,没有遵循Java虚拟机规范,不能称为Java虚拟机,在Android2.2提供了即时编译器
Android5.0以前使用的Dalvik VM,此后替换为支持提前编译技术[AOT]的ART VM
提前编译指可以直接把源文件不经过字节码直接编译成机器指令,执行效率更高
不能直接执行class格式的字节码文件,执行dex格式的文件,dex格式文件可以通过Class文件转化得到,使用Java语法编写应用程序,可以直接使用大部分Java API
安卓应用程序都是以.apk结尾的文件,改成zip解压,里面有大量.dex为后缀的文件,就相当于Java中的.class文件
采用基于寄存器的指令集架构,执行效率高,和硬件耦合度高
Graal VM:2018年oracle发布,在HotSpot基础上增强而成的跨语言全栈虚拟机,可以作为任何语言的运行平台
支持不同语言中混用对方接口和对象
原理是将语言的源代码编程成虚拟机能识别的类似字节码的中间语言格式,只有Graal VM取代HotSpot的希望是最大的
简述JVM组成结构和各结构功能
JVM结构
1️⃣:类加载子系统:字节码文件依次经过加载、链接和初始化三个环节被加载到JVM内存中在运行时数据区的方法区中生成Class实例
2️⃣:运行时数据区:运行时数据区包含程序计数器、虚拟机栈、本地方法栈、堆区和方法区,方法区和堆多线程共享,虚拟机栈、本地方法栈和程序计数器都是每个线程独一份,运行时数据区对应类为单例Runtime类
PC寄存器也叫程序计数器区域:每个线程一份程序计数器
虚拟机栈区域:每个线程一份虚拟机栈,虚拟机栈的基本单元是栈帧[栈帧的内部结构分为局部/本地变量表、操作数栈、动态链接、方法返回地址]
本地方法栈:本地C类库的方法调用执行栈
堆区:Java中创建的对象主体都分配在堆区,JVM中内存最大的一块空间,GC重点考虑的一块空间,堆区也是多线程共享的资源
方法区:方法区主要存放类信息、常量、域信息、方法信息;方法区是HotSpot虚拟机独有;JDK7以前方法区的落地实现叫永久代,JDK7以后叫元空间[永久代和元空间都是方法区的落地实现]
3️⃣:执行引擎:执行引擎包含解释器、JIT即时编译器和垃圾回收器,负责将字节码指令翻译成机器指令供CPU执行
每当执行完一条指令后程序计数器会更新下一条指令地址,执行引擎从程序计数器获取下一条指令;通过局部变量表中的对象引用定位堆中的对象实例;通过对象头的类型指针[也叫元数据指针]定位当前对象的类元数据
JIT即时编译器将反复执行的热点代码专门编译成机器指令并缓存在方法区方便解释器解释运行的时候直接调用
执行引擎形象解释就是两种语言之间的翻译官
所有JVM的执行引擎输入的都是字节码二进制流,处理过程是字节码的解析执行过程,输出执行结果
大部分程序转换为物理机或者虚拟机执行的机器指令前,需要经过下面两条路径,其中程序源码--词法分析--单词流--语法分析--抽象语法树由Javac即前端编译器完成,形成抽象语法树以后会遍历语法树形成线性的字节码指令流,优化器--中间代码--生成器--目标代码是传统编译原理中程序代码到目标机器代码的生成过程,体现Java半编译型半解释型语言的半编译型;指令流--解释器--解释执行是逐行翻译解释执行的过程,体现Java半解释型半编译型语言的半解释型,Java半编译型半解释型语言的根本原因是字节码交给操作系统和CPU执行时既可以使用解释器,也可以使用即时编译器;Java一开始没有即时编译器因此最开始就只是解释型语言,后来发展出了可以根据字节码直接生成本地机器代码的即时编译器可以在方法区缓存被翻译的本地代码方便某段代码被频繁调用直接使用缓存的机器指令而无需再被解释器解释翻译,JVM执行Java代码时通常都会将解释执行与编译执行二者结合起来进行
前端编译器即Java源码级编译器对代码的处理流程:源代码--词法分析器--Token流--语法分析器--语法树/抽象语法树--语义分析器--注解抽象语法树--字节码生成器--JVM字节码,这部分和JVM没有关系
JVM的执行引擎对JVM的字节码处理流程:
JVM字节码--机器无关优化--中间代码--机器相关优化--中间代码--寄存器分配器--中间代码--目标代码生成器--目标代码,由JIT即时编译器负责
JVM字节码交由字节码解释器逐行解释执行
解释器:对应解释流程指令流--解释器--解释执行,负责根据预定义的规范将字节码指令逐条翻译为所在平台的本地机器指令执行
一条字节码指令被解释执行完毕后,解释器再根据程序计数器中记录的下一条等待被执行的字节码指令执行解释操作
古老的字节码解释器:就是将字节码逐条翻译成字节码指令并执行,效率非常低
现在普遍使用的模板解释器:将每条字节码和一个模板函数关联在一起,通过模板函数直接产生当前字节码执行时的机器码,显著提高解释器的性能
不管使用字节码解释器还是模板解释器凡是基于解释器执行都认为是低效的代名词,也是这个原因被C/C++程序员调侃,很多语言都有解释器,包括C和C++后面也补充了解释器,但是只有解释器就会成为低效的代名词;JVM提供及时编译器避免函数被解释执行,将整个函数体编译成机器码并缓存起来,再想执行这部分代码直接使用已经编译好的机器码,通过这种方式大幅提高程序的执行效率,避免冗余的将源码先编译成汇编语言,再将汇编语言汇编成机器语言的过程
解释器的优势是JVM一启动就能直接拿着字节码开始逐行执行,但是JIT编译器需要先把一定范围的字节码全部翻译成机器指令以后才能执行,解释器启动快,单条字节码执行慢;JIT编译器要先编译再执行,启动慢,单条字节码执行快
JIT即时编译器Just In Time Compiler:对应编译流程优化器--中间代码--生成器--目标代码,将字节码指令编译成本地机器平台相关的机器指令并缓存起来,并不像解释器一样立即执行
典型优势就是速度快,特别是代码大量复用的场景,就像是提前把菜切好,炒菜的时候不需要处理菜直接用,最重要的是超过的菜还可以复用
编译器可以指前端编译器也可以指后端编译器,前端编译器负责将.java文件编译成.class文件,后端运行期编译器就是JIT即时编译器,负责将字节码转变成汇编再转变成机器码;典型的前端编译器比如JDK中的Javac、Eclipse JDT中使用自己研发的增量式编译器ECJ;典型的JIT编译器比如HotSpot中的C1、C2编译器;此外编译器还可以指静态提前编译器AOT编译器[Ahead of Time Compiler],这是java发展的一个趋势
查了一下java常规编译过程不直接设计汇编,AOT编译器内部可能涉及汇编的生成和优化
JIT编译器会根据代码被调用执行的频率判断字节码是否为热点代码,将热点代码进行即时编译
热点代码:被多次调用的一个方法,或者一个方法体内循环次数较多的循环体都可以成为热点代码
栈上替换:这种在方法执行过程中对热点代码的编译方式被称为栈上替换,也称为OSR[On Stack Replacement]编译
HotSpot采用基于计数器的任店探测方式来探测热点代码,HotSpot会为每个方法都建立方法调用计数器和回边计数器两个计数器;方法调用计数器统计方法的调用次数,回边计数器统计循环体执行的循环次数
方法调用计数器在Client模式下的默认阈值为1500次,Server模式下的默认阈值为10000次,只要超过该阈值就会成为热点代码触发JIT编译,该阈值可以通过JVM参数-XX:CompileThreshold设置
方法调用时会首先检查当前方法是否已经被JIT编译过,已经被编译过会直接调用编译后的机器码直接执行,如果还没有被编译过让方法调用计数器加1,检查计数是否超过阈值,没超过由解释器解释执行,超过向JIT编译器提交编译请求,编译缓存到方法区以后再被调用执行
方法调用计数器统计的是一段时间之内方法被调用的次数,超过一定时间调用次数还没有达到阈值方法调用计数器会减少到原计数的一半,该过程称为方法调用计数器的热度衰减,该时间称为当前方法统计的半衰周期;可以通过配置JVM参数-XX:-UseCounterDecay来关闭热度衰减,此时方法调用计数器统计的是方法被调用的累计绝对数,只要系统运行时间足够长,绝大部分方法都会被编译为本地方法;可以通过JVM参数-XX:CounterHalfLifeTime设置半衰周期的时间,默认单位为s
回边计数器统计一个方法中循环体代码被执行的次数,回边指字节码中遇到控制流向已经被执行过的指令跳转的指令
遇到回边指令时检查循环体是否已经被JIT编译过,如果已经编译过执行循环体已经被编译过的本地代码;如果没有编译过,会让回边计数器加1,判断两个计数器的和是否超过阈值,没有超过阈值由解释器解释字节码执行;超过提交OSR编译请求JIT编译循环体并缓存再调用机器指令执行
JVM运行时可以通过命令参数显式指定运行时只采用解释器执行还是只采用即时编译器执行
-Xint:只采用解释器模式执行程序,JVM工作在interpreted mode下
一个简单的几条语句的循环体被执行100万次,纯使用解释器执行时间为6520ms
-Xcomp:只采用即时编译器模式执行程序,当即时编译出现问题解释器会介入执行,JVM工作在compoled mode下
一个简单的几条语句的循环体被执行100万次,纯使用解释器执行时间为950ms
-Xmixed:采用解释器+即时编译器混合模式执行程序,JVM工作在mixed mode下
一个简单的几条语句的循环体被执行100万次,纯使用解释器执行时间为936ms
JVM内嵌C1和C2两个JIT编译器,C1也叫Client Compiler,C2也叫Server Compiler,可以通过命令参数显式选择具体使用哪一种即时编译器,64位的操作系统只支持server版本,即使指定了-client也会忽略掉
-client:指定JVM运行在Client模式下,使用C1编译器
C1编译器对字节码的优化简单,编译速度快
C1的优化策略主要有方法内联、去虚拟化、冗余消除;
方法内联:将引用的方法代码编译到引用点处,不再让每次方法的调用都创建栈帧,将所有该方法的调用都指向一块空间
去虚拟化:对接口唯一的实现类进行内联
冗余消除:将运行期间不执行的代码直接折叠掉
-server:指定JVM运行在Server模式下,使用C2编译器
C2编译器对字节码的优化更激进深入,编译耗时长,但是优化后的代码执行效率更高
C2的优化是基于逃逸分析的优化,包括标量替换、站上分配和同步消除
但是Server模式下也不是只用C2不用C1,JVM采用分层编译策略,不开启性能监控的情况下程序使用C1编译进行简单优化,开启性能监控后使用C2根据性能监控信息进行激进优化,JDK7以后,Server模式默认就开启了分层编译策略即开启了性能监控;C2编译器启动时长比C1长,但是系统稳定后,C2编译器编译出来的代码执行速度远远快于C1编译器
一般JIT编译出来的机器码性能比解释器高
JDK10开始HotSpot加入一个Graal即时编译器,对应有一个Graal VM,该编译器的编译效果已经赶上了C2编译器,目前还带实验标签,即不同的版本可能修改或者移除,带有一个实现标识,可以通过JVM参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler激活使用,Graal和C2并列的关系,Graal属于即时编译器
JDK9引入AOT静态提前编译器,AOT是和JIT并列的关系,是与即时编译对立的概念,Java9引入实验性的AOT编译工具jaotc,在用户运行程序前可以手动将字节码文件编译成.so文件,直接就将字节码编译成机器码
优势是JVM在启动之前就将字节码预编译成二进制库,JVM可以直接加载预编译的二进制机器码直接执行,无需预热,启动就执行机器指令
缺点是打破了Java的跨平台特性,同一份.so文件不能在不同硬件和操作系统上执行;丧失Java链接的动态性,运行前就需要明确所有的机器指令,JVM运行前需要明确所有的代码,失去Java的动态链接特性;目前AOT编译器仅支持Linux 64位
HotSpot采用解释器和即时编译器并存的架构,JVM运行时解释器和即时编译器相互协作,由HotSpot决定何时使用解释器,何时使用即时编译器
JRockit因为主攻服务端市场,服务端应用对启动时间不关注,重点关注响应时间,完全抛弃解释器的JRockit启动时也必须花费更长的时间来对字节码进行编译,但是换来了高效的执行性能
客户端市场对启动时间也有一定的需求,比如IOS的启动时间快,图标一点就有反应,安卓虽然功能强大,但是很多时候点图标半天才有响应;因此对于看中启动时间的应用场景,需要采用解释器与编译器并存的架构来在启动时间和响应时间之间找一个适用于场景的平衡点
解释器与即时编译器并存的架构在热机状态下相较于冷机状态能承受更大的负载,如果以热机状态的可承载流量进行切流,可能导致处于冷机状态下的服务器因为无法承载住流量而假死;典型的比如生产环境发布过程的分批发布,一般将正在运行的机器数量划分为N个批次,每次对其中一个批次的机器进行发布更新,一般每个批次的机器数最多站总集群的1/8;阿里有过一个案例,一程序员在发布平台填写总发布批数的时候因为认为热机状态下一半的机器就能承载当前负载,就选择了分两批发布,结果第一次成功更新后的JVM刚启动都是解释执行,还没有对热点代码进行统计和即时动态编译,停掉另一半的机器此时刚发布成功后的机器全部处于冷机状态,无法承载当前的流量,导致第一批发布成功的服务器全部宕机;
此外,阿里的限流框架Sentinel对资源的流控规则也设计有预热模式,当系统流量长期处于低水位的情况下,流量突然增加直接把系统拉升到高水位也可能瞬间把系统压垮。预热模式在QPS超过某个阈值默认值为3的情况下通过限制通过的流量,让流量在一定时间内默认值是5s逐渐增加到预设的流量阈值上限的冷启动方式,给冷机一个预热的时间,避免冷机被突增的高流量压垮压垮。使用这种限流框架也能防范上述分批发布冷热机切换存在的风险
4️⃣:本地方法接口:包括本地方法接口JNI和本地方法库,在Java核心API中有一些没有方法体被native关键字修饰的方法,这些方法就是本地方法,实际上这些方法有方法体,只是底层已经用C/C++实现了;使用native关键字修饰的Java方法就是本地方法;native关键字和abstract关键字不能共用;本地方法主要是为了在Java平台在某些场景下使用C/C++来完成这些任务
本地方法存在的主要原因是与Java外的环境交互,和操作系统以及一些硬件交互时必须使用C/C++,本地方法提供由C/C++实现的接口来供Java直接调用,操作系统主要还是由C/C++编写的,JVM不是一个完整的操作系统,必须依赖于操作系统的支持,Java通过C实现的本地方法和操作系统进行交互,此外Java中的一些方法需要直接和操作系统打交道这些方法都是由C实现的[类似于老美制定的国际标准,完全不使用这套标准还是有一定困难的]
Sun的解释器是用C实现的,jre大部分是用Java实现的,部分方法是用C实现并植入JVM内部,在底层很多的本地方法都是由外部的动态链接库提供被JVM调用
以前与硬件交互比如由Java驱动打印机或者管理生产设备还需要用户编写本地方法,现在随着Java的发展,这种现象已经很少见了

JVM内存区域解析[内存区域就是运行时数据区,其中最重要的是虚拟机栈、堆和方法区]
线程:线程是一个程序的运行单元,操作系统中有很多进程,单个进程中有很多线程,JVM中每个线程对象都和操作系统的本地线程存在一一对应关系;Java线程准备好执行[准备过程是准备线程对应的程序计数器、虚拟机栈、本地方法栈等]调用start方法后本地线程才会创建,本地线程创建并初始化成功后,操作系统就会调用Java线程中的run方法,Java线程执行结束后本地线程也会对应回收,如果Java线程出现异常Java线程会直接结束,本地线程还会决定JVM进程是否终止,判断依据是判断当前线程是否JVM进程的最后一个用户线程
HotSpot VM后台运行的守护线程主要有以下几类[通过jconsole可以查看这些线程]
虚拟机线程:JVM运行达到安全点,虚拟机线程负责stop-the-world垃圾收集、线程栈收集、线程挂起以及偏向锁撤销
周期任务线程:负责周期性任务的调度执行
GC线程:专门对JVM中不同类型的垃圾进行回收
编译线程:将字节码编译成本地机器指令
信号调度线程:接收信号并发送给JVM,JVM通过该线程对事件进行适当处理
运行时数据区:运行时数据区包含程序计数器、虚拟机栈、本地方法栈、堆区和方法区,方法区和堆多线程共享,虚拟机栈、本地方法栈和程序计数器都是每个线程独一份,运行时数据区对应类为单例Runtime类
PC寄存器也叫程序计数器区域:程序计数器是从软件层面对CPU寄存器的抽象模拟[CPU只有把数据装载到寄存器中才能运行],也被称为程序钩子
功能是存储虚拟机栈中栈帧[栈帧就是线程执行的当前方法]的操作数栈中的下一条将被执行的指令的指令地址/偏移地址,执行引擎根据该内存地址去虚拟机栈的局部变量表和操作数栈读取下一条字节码指令;如果当前线程正在执行本地C类库方法,程序计数器的值为undefined
注意后面又变了,老师和《深入理解Java虚拟机》中都说程序计数器记录的是当前正在被执行指令的指令地址,弹幕说操作系统中CPU里面的程序计数器指向的是下一条指令,JVM规范中明确指出这里的程序计数器指向的是当前正在被执行的指令
每个线程一份程序计数器,生命周期与线程生命周期保持一致
PC寄存器没有垃圾回收的概念,也不会发生OOM内存溢出风险;虚拟机栈和本地方法栈没有垃圾回收,但是有可能会发生OOM风险;堆区和方法区有垃圾回收也可能发生OOM风险
虚拟机栈区域:每个线程一份虚拟机栈,虚拟机栈的基本单元是栈帧[栈帧的内部结构分为局部/本地变量表、操作数栈、动态链接、方法返回地址],每个栈帧对应一个方法
指令集架构模型[有基于栈的指令集架构和基于寄存器的两种指令集架构]
基于栈的指令集架构
每执行一个方法就对方法做一次栈帧的入栈操作,方法执行完以后做一次栈帧的出栈操作
基于栈的指令集架构一般都是零地址指令
基于栈的字节码指令以每八位单字节的方式对齐,基于寄存器的指令以十六位双字节的方式对齐;单个指令字节数更小,总的指令数量更多
特点[跨平台、指令集以零地址指令为主,单条指令占用空间小、编译器容易实现,缺点是基于内存相对性能不高,同样的功能需要更多的指令]
基于寄存器的指令集架构
x86的二进制指令集和传统PC以及安卓的Davlik虚拟机都是基于寄存器的指令集架构
特点[基于CPU高速缓冲区性能高,与硬件耦合度高可移植性差,指令集以一、二、三地址指令为主,单条指令占用空间大,但同样功能需要的指令数更少]
堆和栈的区别
栈是运行时的单位解决程序如何执行的问题、堆是存储的单位解决数据放在哪儿怎么放的问题[也不绝对,对象在堆中,基本数据类型的局部变量还是在栈中,对象作为局部变量栈中只是存放的对象引用而不是对象本身]
虚拟机栈主管Java程序的运行,保存方法的局部变量、部分结果,参与方法的调用和返回
栈的特点
栈是仅次于程序计数器的快速分配存储方式,只针对栈顶对栈帧压栈出栈,操作简洁;因为栈的工作特性也不存在垃圾回收问题,可能存在OOM
虚拟机栈[早期叫Java栈]在每个线程创建时都会创建一个私有的虚拟机栈,虚拟机栈的基本单元是栈帧,一个栈帧对应一次Java方法的调用,虚拟机栈生命周期与线程一致
如果在线程创建时指定了虚拟机栈的容量,如果线程实际分配的栈容量超过虚拟机栈的容量最大值,Java虚拟机会抛出一个StackOverflowError异常
如果虚拟机栈的容量设置为可动态扩展,只有在虚拟机栈尝试扩展时无法申请到足够内存或者创建新线程时没有内存去创建虚拟机栈时,虚拟机会抛出OutOfMemoryError异常
虚拟机栈的大小可以通过虚拟机系统参数VM options指定为-Xss256k来设置,默认虚拟机栈的大小约1M,要避免虚拟机栈的容量大小低于单个线程运行需要的容量,否则会报栈溢出异常,但是虚拟机栈可以动态扩展,这点没有考虑进来
虚拟机栈结构
虚拟机栈的基本单位是栈帧,一个栈帧对应一个方法,栈帧维护方法执行期间的各种数据信息,虚拟机栈对栈帧压栈和出栈
一个活动线程一个时间点上只有栈顶栈帧是活动有效的,称为当前栈帧;当前栈帧对应方法称为当前方法;定义该方法的类称为当前类
执行引擎只运行当前栈帧中的字节码指令,程序计数器中存储的也是当前栈帧中的指令地址
当前方法返回时,当前栈帧会将当前方法的恶之星结果返回给前一个栈帧
Java方法有两种返回函数的方式,一种是正常的函数返回,一种是抛出未被处理的异常,两种方式都会使当前栈帧被弹出
栈帧结构
栈帧由局部变量表、操作数栈[表达式栈]、动态链接[也称指向运行时常量池的方法引用]、方法返回地址[也称方法正常或异常退出的定义]、一些附加信息五部分组成,重点是局部变量表和操作数栈[栈帧的大小主要就取决于局部变量表和操作数栈的大小],动态链接、方法返回地址和一些附加信息也被称为帧数据区
局部变量表:局部变量表是一个数字数组,存储方法参数和定义在方法体中的局部变量,存储的数据类型为基本数据类型、对象引用和方法返回值地址,存储的数据主要是非静态方法的this指针、形参、方法内部定义的局部变量值,注意不是变量名
基本数据类型中byte、short、long、int、double、float本身就是数字,char也有对应的ASCII码或Unicode码,存储前会被转换为int,boolean存储前也会被转成int,八种数据类型都可以用数字表示
局部变量表线程私有,不存在数据安全问题
一个方法的局部变量表的容量在编译阶段就确定了,保存在方法的Code属性的maximum local variables属性中,Code属性中的Code length表示方法赌赢字节码的指令行数,方法运行期间不会改变局部变量表的大小;通过javap指令和jclasslib插件都能查看一个类中所有方法的局部变量表信息和相应的作用字节码范围,局部变量表中的变量按照方法中的声明顺序排列
局部变量表的基本单位是变量槽Slot,占32个比特位的数据类型即除long和double的其他数据类型包括对象引用只占一个Slot,long和double占用两个Slot,即一个Slot占32个比特位四个字节;使用变量起始位置的索引对变量进行引用
实例方法和构造方法中可以调用变量this指代当前对象,这是因为实例方法和构造方法在创建时,当前对象的引用this会存放在局部变量表的第一个变量槽中,而静态方法的局部变量表中没有该操作,因此静态方法中不允许使用this
方法中没有使用变量接受一个有返回值的被调用方法执行结果,当前方法的局部变量表中不会为被调用方法的返回值分配空间
一张局部变量表中的变量槽中的变量如果作用域比方法的作用域小,在作用域后面声明的局部变量会在变量槽失效后占用该变量槽来节省空间,这中占用关系在编译时就决定好了
局部变量表中的变量是垃圾回收的根节点GC Roots,只要被局部变量表直接或间接引用的对象都不会被回收,因此局部变量表也是性能调优重点关心的区域
方法调用时形参的传参就是通过局部变量表来进行传递的
操作数栈:也叫表达式栈,操作数栈根据字节码指令被压栈或者出栈字节码指令操作的数据,JVM的执行引用是基于操作数栈的执行引擎,操作数栈管理字节码执行过程中的所有数据,此外执行引擎还会将字节码指令翻译成机器指令结合操作数栈的数据交给CPU执行,并将CPU的执行结果再存入操作数栈中;操作数栈用于保存计算过程的中间结果,同时作为计算过程中变量的临时存储空间;操作数栈的作用是存放字节码指令的操作数和返回结果;执行指令前,JVM会将指令的操作数压入操作数栈,执行时将指令需要的一个或多个操作数弹出,指令执行结束后将执行结果重新压入栈中;操作数栈中弹栈压栈的是变量值
操作数栈在栈帧创建时一同创建,操作数栈是一个容量确定的数组,具体容量在编译时就会被定义,通过方法的Code属性中的max_stack属性值能查到操作数栈的具体容量;对局部变量初始化时会先根据字节码指令将数据存入操作数栈,再从操作数栈将数据出栈存入局部变量表;对数据进行运算会先从局部变量表将数据压栈到操作数栈,再从操作数栈弹栈交给CPU执行,将执行结果压栈到操作数栈,再从操作数栈弹栈存入局部变量表
long和double类型的数据占两个栈单位深度,此外其他类型32位占一个栈单位深度;byte、short、char、boolean都会以int类型来保存
如果被调用的方法有返回值,被调用方法返回后返回值会从上一个栈帧的操作数栈弹栈然后被直接压入当前栈帧的操作数栈中
栈顶缓存技术[ToS]:因为频繁的入栈出栈操作导致更多的指令分派和内存读写次数从而降低程序的执行速度,HotSpot VM的设计者提出栈顶缓存技术,原理是将栈顶元素全部缓存在物理CPU寄存器中,减少数据的频繁入栈出栈操作,让CPU直接操作寄存器中缓存的数据,减少压栈弹栈次数
动态链接:动态链接保存着当前栈帧指向方法区中运行时常量池中对应方法的符号引用,该符号引用的目的是为了让当前方法的代码能实现动态链接,动态链接对应静态解析,下面主要介绍方法调用
Java源文件中的所有变量和方法引用在被变成字节码时会被编译成符号引用保存在字节码文件的常量池中,字节码文件中专门有一个区域称为常量池,字节码文件被加载到方法区以后常量池对应的是运行时常量池,返回值为空这个空值也存在于常量池中,凡是返回空参的方法都会引用该常量池中的空参;类变量比如int类型对应的常量I也会存在常量池中,符号引用就是字节码文件常量池中的#数字的标识,符号引用可以指向一个常量本身也可以指向其他常量的符号引用,常量池就是为了提供符号和常量,节省资源,使用的时候直接通过符号来识别具体的资源并进行调用
字节码文件将类用到的类、类中定义的方法、类变量、字符串等存入常量池并生成对应的符号引用,字节码指令通过符号引用在常量池中找到对应的信息来进行使用,比如在一个方法中描述调用了另外的方法,这是为了同一份资源的共用,节省资源,同时通过方法引用实现子类调用父类的方法引用来实现多态的设计思想
静态链接:字节码文件装载到JVM时如果其中的具体方法在编译阶段已知且运行期间保持不变,在编译期间就能确定符号引用和直接引用的对应关系就称为静态链接
早期绑定:在编译期间就能确定被调用的具体方法内容,此时符号引用就能直接指向方法、字段或者类的直接引用,此时就会使用静态链接的方式,典型场景就是不使用多态或者super.eat()指定调用父类中的eat()方法,指定调用父类的构造方法super(),单参构造通过this()调用无参构造
面向过程的语言只有早期绑定、面向对应的语言都支持封装、继承、多态,都同时具备早期绑定和晚期绑定两种方式;Java中任何一个方法都可以拥有C++中虚函数的特征,即多态通过父类型引用指向子类型实例来实现对子实例方法的调用,如果不希望某个方法拥有虚函数的特征可以使用final关键字来进行标记,即多态中对final修饰方法的调用还是调用的父类的对应方法引用,对应的final修饰的方法不能被子类重写,这是对多态的理解
非虚方法:编译期间就确定了被调用方法的具体版本,且运行时不会发生改变的方法,静态方法、私有方法、final修饰方法、实例构造器、通过super调用父类方法都是非虚方法,除了这五种情况剩下的方法都是虚方法;字节码中invokestatic和invokespecial指令是调用非虚方法的指令,invokestatic调用静态方法,invokespecial调用构造器、私有或者调用父类方法;invokevirtual和invokeinterface调用所有虚方法以及final修饰的非虚方法,final修饰非虚方是通过invokevirtual调用的;此外还有一个invokedynamic指令可以动态解析出需要调用的方法,该指令在jdk7引入,目的是为了实现动态类型语言,jdk7需要使用ASM字节码工具来生成invokedynamic指令,jdk8的Lambda表达式让该指令可以直接在方法执行过程中动态生成
静态类型语言:Java语言本身是静态类型的语言,有了invokedynamic指令后Java语言就具备了动态类型语言的特性,在编译期间就对数据类型检查的语言是静态类型语言,如果数据字面值不满足变量的指定类型编译就会报错,静态类型语言判断变量自身的类型信息
动态类型语言:在运行期间对数据类型检查的语言是动态类型语言,像JS和Python就是动态类型语言,动态类型语言判断的额是变量值的类型信息,变量没有类型信息、变量值才有类型信息;只能根据变量值才能确定一个变量是什么类型的,虚拟机引入该invokedynamic是为了在JVM上支持动态类型语言的运行
动态链接:方法只能在程序运行期间将符号引用转换成直接引用称为动态链接
晚期绑定:编译期间不能确定被调用的具体方法,只能在运行期间根据实际类型确定被调用的具体方法,就会通过动态链接的方式来实现符号引用指向直接引用,典型场景就是多态[子类对象多态性的前提是类的继承关系和方法的重写]
方法重写的本质:通过方法调用的字节码指令将执行对象实例的实际类型压栈到操作数栈栈顶,通过该类型去常量池中找名字描述一致的方法,并进行权限校验,校验通过直接找到方法的直接引用,校验不通过抛出IllegalAccessError异常;按照继承关系从下往上依次对该类型的和各个父类进行上述常量池搜索和校验的过程直到找到具体的方法,如果遍历到最顶层的类或者接口还没有找到具体的方法就会抛出AbstractMethodError抽象方法异常;为了避免频繁动态分派在类的方法元数据中搜索合适的目标来提高性能,JVM在类的方法区建立一个虚方法表,虚方法表中存储着各个虚方法的实际入口,只要虚方法被动态分配一次,后续调用就能直接通过虚方法表找到对应的方法入口而不再需要进行搜索,虚方法表在类加载的链接环节的解析步骤被创建并开始初始化,类变量初始化完成后虚方法表也被初始化完毕,子类重写过的虚方法在虚方法表中执行子类自身的虚方法
方法返回地址:方法返回地址保存调用当前方法的方法调用当前方法时的程序计数器的值,即调用当前方法的方法的下一条指令,正常执行结束的方法通过方法返回地址来确定上一个方法下一条应该被执行的指令;说白了就是退出当前方法前先将PC寄存器中的值设置为当前方法方法返回地址中的值,让当前线程去执行上一个方法的下一行代码
特别注意异常退出当前方法通过异常表来确定上一个方法下一条应该被执行的指令
异常表就是在指定字节码行号范围内出现的异常根据异常类型匹配执行指定异常对应字节码指令起始行号指令,异常表可以通过字节码中对应方法的Exception table来查询
正常退出会给调用者返回当前方法的返回结果,但是异常退出不会给上层调用者返回任何结果,没有被处理的异常会被抛给上层调用者
当前方法正常调用完成后需要根据返回值的实际数据类型来确定使用哪一个返回指令,boolean、byte、char、short、int对应的返回指令为ireturn,long对应指令为lreturn,float对应指令为freturn、double对应指令为dreturn,引用类型对应指令为areturn,void、构造器、类和接口的<clinit>()方法对应的指令为return
一些附加信息
附加信息不同的JVM实现允许携带的附加信息不同,比如设置对程序调试提供支持的信息
本地方法栈:本地C类库的方法调用执行栈,虚拟机栈管理Java方法的调用,本地方法栈管理本地方法的调用,本地方法指被Java语言调用用C实现的方法
本地方法栈是线程私有的,本地方法栈也可以设置固定容量或者设置为可动态扩展内存,同样固定容量实际使用量大于固定容量抛栈溢出异常,动态扩展容量没有足够内存扩容或者创建本地方栈抛内存溢出异常
Java调用本地方法时会将本地方法以栈帧的形式压入本地方法栈,通过动态链接的方式由执行引擎调用本地方法库
本地方法可以直接通过本地方法接口访问虚拟机内部的运行时数据区,还可以直接使用本地处理器中的寄存器和随意使用本地内存
不是所有的JVM都支持本地方法,JVM规范没有要求本地方法栈,HotSpot虚拟机将本地方法栈和虚拟机栈合二为一,直接通过动态链接的方式在本地方法库找到本地方法由执行引擎来执行
堆区:Java中创建的对象主体都分配在堆区,JVM中内存最大的一块空间,GC重点考虑的一块空间,堆区也是多线程共享的资源,堆区分为新生代和老年代
概念
一个JVM实例[一个JVM进程只能运行一个主方法]只有一个堆内存,堆是Java内存管理的核心区域;JVM启动时通过引导类加载器创建运行时数据区的同时创建堆区,堆一创建容量就确定了,堆空间容量可以通过参数-Xms10m -Xmx10m调节即初始堆空间和最大堆空间都是10M[参数在执行java指令运行字节码的时候指定],堆是JVM中最大和最重要的一块内存空间
堆可以处于物理上不连续逻辑上连续的内存空间,不连续的物理内存可以通过映射表在逻辑上视为连续的一块内存
堆空间被所有线程共享,但是每个线程还可以在堆空间开辟线程私有缓冲区即TLAB,多线程并发为了共享数据的安全性需要同步保证对共享数据操作的原子性,会降低系统的性能,TLAB在保证数据安全性的同时保证线程并发性能
几乎所有的对象实例和数组都应该分配在堆上,JVM为了提高性能,引入了逃逸分析,如果方法创建的对象没有发生逃逸可以把对象分配在虚拟机栈上
对象使用完毕不会马上从堆中移除,仅在垃圾回收阶段才会被移除,这是为了避免频繁GC影响用户线程的执行降低系统性能[GC会暂停JVM中的所有线程];堆是垃圾回收的重点区域,在大内存对象和频繁垃圾回收的情况下垃圾回收会成为系统性能的瓶颈
创建对象的字节码指令是new,创建数组的字节码指令是newarray
堆区通过系统参数-Xms或-XX:InitialHeapSize设置堆区的起始内存,通过系统参数-Xmx或-XX:MaxHeapSize设置堆区的最大内存
-X表示这是jvm的运行参数,ms表示memory start初始内存大小,默认单位是字节数,k或者K表示KB,m或者M表示MB
默认情况下,初始内存大小是物理电脑内存大小的1/64,最大内存为物理电脑内存大小的1/4;通常-Xms和-Xmx会配置相同的值,目的是为了在垃圾回收清理完堆区后不需要重新计算堆区的大小[因为最大容量和初始容量不同系统每次垃圾回收后都会重新计算应该给堆区分配多少内存,消耗系统性能],提高系统性能;注意这个物理内存是可用内存,实际上操作系统还会使用约几百M的内存,这部分内存对用户来说是不可用的
通过Runtime.getRuntime().totalMemory()或者Runtime.getRuntime().maxMemory()可以分别获取当前虚拟机的堆内存容量和最大堆内存容量
jstat -gc 进程号命令可以查看JVM在GC时的统计信息,其中OC是老年代的总堆内存、OU是老年代已经使用了的内存,对应的EC、S0C、S1C分别对应伊甸园区、幸存者0区、幸存者1区的内存
注意幸存者0区和幸存者1区只能二选一使用,始终有一个空间是空的,JVM统计堆的总大小的时候只会总计其中一个幸存者区的大小,因此实际上堆区的大小比JVM统计的值要略大
在jvisualVM上的抽样器选项卡的抽样属性的内存中能看到每种类型数据占用的容量
堆内存细分
现代垃圾收集器大部分基于分代收集理论设计,对应分代收集算法
分代的唯一理由就是优化GC性能,如果没有分代,每次垃圾回收都要判断所有的对象是否需要垃圾回收,而不是降低对长生命周期对象垃圾回收垃圾判定的频率,就会导致每次垃圾回收的效率都会越来越低
JDK7以前将堆逻辑上分为新生代、老年代和永久代;JDK8以后将堆逻辑上分为新生代、老年代和元空间
新生代也叫新生区、年轻代;老年代也叫养老区、老年区
新生代分为伊甸园区、幸存者0区、幸存者1区[幸存者区也叫from区或to区]三部分,注意每个幸存者区都可能是from区和to区
注意逻辑上的堆空间包含新生代、老年代和元空间,实际上的堆空间容量只包含新生代和老年代两部分的总容量
通过系统参数-XX:+PrintGCDetails能在程序运行的时候打印当前JVM垃圾回收器的细节,该参数配置以后在程序运行结束后才会在控制台打印,会展示堆区和方法区的容量细节
JDK8永久代变化为元空间也引起了Stringtable字符串常量池和静态域结构的变化
Java对象可能是瞬时对象也可能极端情况下生命周期和JVM一样长,像连接对象的生命周期就会长一些;
为了避免每次GC都判断生命周期很长的对象是否进行回收,将这些生命周期可能很长的对象放入老年代来降低GC判断的频率
几乎所有对象最初都在伊甸园区创建,一定条件下伊甸园区的对象还没有销毁就存入幸存者0或者幸存者1区;一定条件下幸存者区的对象还没有销毁就存入老年代
只有创建的对象特别大,大到伊甸园区都放不下,此时就会直接在老年代创建该对象
IBM公司专门研究表明新生代中80%左右的Java对象的销毁都在新生代进行
JVM参数-XX:NewRatio=2可以设置新生代老年代的内存比例,该参数示例表示新生代占1份,老年代是新生代的两倍,新生代占整个堆区的1/3,这也是JVM的默认配置,实际生产该参数一般不会调整,只有非常明确系统中很多对象的生命周期都很长才会将老年代的比例调大一些
jps指令和jstat指令配合或者使用jvisualvm都能查看堆区的细分区域大小
jinfo -flag NewRatio 进程号指令能查看指定进程JVM参数NewRatio的值
可以通过JVM参数-Xmn设置新生代的最大内存大小,该参数优先级大于-XX:NewRatio=2,两者冲突的情况下会优先采用-Xmn的设置,总的堆容量减去新生代的容量得到老年代的容量
默认情况下HotSpot虚拟机中,伊甸园区和两个幸存者区的内存空间占比JVM官方文档说是8:1:1,但实际上不是8:1:1,此外还可能受JVM的自适应机制影响,通过JVM参数-XX:-UseAdaptivePolicy可以关闭自适应机制[开启内存分配自适应还可能导致两个幸存者区不一样大],-Use的-表示不使用,如果替换成+表示使用;但是实际配置关闭自适应机制以后没有实现对应效果还是原样;要想完全自定义伊甸园区和幸存者区的比例需要使用JVM参数-XX:SurvivorRatio=8来自定义设置伊甸园区是单个幸存者区容量的8倍
方法区:方法区主要存放类元信息、常量池、域信息、方法元信息;方法区是HotSpot虚拟机独有;JDK7以前方法区的落地实现叫永久代,JDK7以后叫元空间[永久代和元空间都是方法区的落地实现],此外方法区还缓存JIT即时编译产物
方法区也是存在垃圾回收的,但是JVM规范并没有强制要求所有虚拟机都要实现方法区垃圾回收,元空间一般来说空间占用是比较稳定的,GC不像方法区那么频繁;方法区主要存放运行时常量池,类的属性和方法数据,方法和构造器的字节码[JDK8及以后运行时常量池移动到堆中了],以及类或者对象实例初始化时使用到的特殊方法比如<clinit>方法或者<init>方法等
方法区在运行时数据区创建时启动,在JVM进程结束时销毁,JVM规范指出将方法区逻辑上看做堆的一部分,但是具体的实现可以选择不对方法区进行垃圾回收和压缩。堆区是要求要垃圾回收,以及为了避免存储碎片的问题会对存储空间进行整理和压缩;JVM规范不限制方法区的位置和方法区的管理策略;方法区可以设置成固定大小,也可以在运行时动态的扩缩容,方法区的内存和堆一样不要求是物理上的连续空间;HotSpot的方法区还有一个别名叫非堆,目的就是要将方法区和堆分开,可以认为JVM规范逻辑上认为方法区是堆的一部分,但是实际的实现因为不进行GC和压缩,要把方法区和堆区分开
方法区大小决定系统可以保存多少个类,如果系统定义的类太多,可能会导致方法区溢出跑OOM异常,方法区在JDK7及以前的实现叫永久代,JDK8以后叫元空间;默认加载的类就非常多,随便写一个简单的代码,加载的类的数量就能达到1674个左右,加载大量的第三方jar包比如Tomcat部署30到50个工程就可能出现方法区溢出的问题;大量地动态生成反射类也可能会造成类过多导致方法区溢出
JDK8中类的元数据存储在被称为元空间的本地内存[非JVM内存,就是本机的可用物理内存]中,方法区和永久代不能等价,BEA的JRockit和IBM的J9中不存在永久代的概念,JDK7以前永久代还是使用JVM的内存,可以通过参数-XX:MaxPermSize设置永久代的大小,JVM的内存空间有限,容易抛OOM异常,像JRockit和J9一样使用本地内存方法区的内存可以达到几个GB,不容易出现OOM;JRockit是世界上公认的速度最快的虚拟机,HotSpot和JRockit合并的时候内存不统一,采用了JRockit更好的方法区设计;元空间和永久代本质的区别就是元空间使用本地内存,永久代使用JVM内存,此外还更改了一些细节比如将运行时常量池和静态变量移动到堆空间
JDK7以前的版本可以通过-XX:PermSize来设置永久代的初始空间,默认是20.75MB;通过-XX:MaxPermSize来设定永久代的最大可分配空间,32位操作系统默认是64MB,64位操作系统默认是82MB,加载的类信息容量超过该最大可分配空间会抛OOM异常;这两个参数在JDK8中已经被废弃了,如果在8中主动设置在程序运行结束后会提示参数已移除
元数据区大小可以使用参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize指定,默认值和操作系统平台有关,windows平台默认情况下初始空间约21MB,最大可分配空间值为-1,表示没有限制,即以本地内存的最大值作为方法区的最大值
一旦系统的方法区达到初始空间大小就会触发Full GC卸载没用的类,对应的类加载器被释放,然后根据GC释放的容量大小来决定方法区新的内存空间,如果释放的少,就适当的提高;如果释放的空间过多,则会适当降低该值;初始值太低会频繁触发Full GC,建议将方法区设置的尽量高一些
虚拟机栈中存放对象的引用,引用指向堆空间中的对象实例,对象实例中存放了一个到对象类型数据的指针,指针指向创建对象实例的class对象
方法区的演进
JDK1.6及以前经典方法区主要存储被虚拟机加载的类型信息[域信息、方法信息]、运行时常量池、静态变量、即时编译器编译后的代码缓存,06年建立OpenJDK的时候就有更改永久代的想法,Oracle在08年收购了JRockit虚拟机,在14年发布JDK8的时候将永久代移除,因为方法区的具体实现不受JVM规范约束,因此不同的Java虚拟机实现并不统一
JDK1.7将字符串常量池和静态变量从永久代移动到堆中,永久代字符串常量池存在运行时常量池中,后续只是将字符串常量池移到堆中,运行时常量池还是在永久代中
将StringTable转移到堆空间的原因:Full GC只有在老年代空间不足,永久代空间不足时才会触发,这就导致字符串常量池的回收效率不高,开发中一般都会有大量的字符串被创建,如果字符串常量池的回收效率低,很容易就会导致永久代的内存不足;将字符串常量池放在堆中是为了即时回收常量池中的内存
静态变量转移到堆中,注意静态变量的值如果是一个对象,该对象还是被创建在堆区,JDK1.6及以前静态变量对应对象创建在永久代中,对象的引用指向堆中的值对象,JDK9开始引入了一个监控JVM进程的jhsdb工具;通过Inspector可以查看class对象实例,发现静态变量随着Class对象存放在一起存储在堆区,只是类的元数据包含类的方法代码、变量名、方法名、访问权限、返回值等才是存放在方法区
成员变量本身是随着创建的对象一起存放在堆区,局部变量本身存放在栈帧的局部变量表中,创建的对象存放在堆中;
JDK1.8及以后,废弃了永久代,将类型信息、字段、方法、常量保存到元空间
使用元空间替换永久代的原因:
实际生产中永久代的空间大小根据项目情况很容易发生变化,项目引入的Jar包过多,项目的功能点较多,都会动态增加系统需要加载的类数量,永久代受到JVM内存的限制很容易出现OOM错误,空间小也容易导致频繁Full GC,本地内存默认最大值为-1,即没有限制,物理内存足够就可以敞开用,还可以避免由方法区内存不足引起的频繁Full GC
方法区的垃圾回收主要回收废弃的常量和不再使用的类,判断类不再进行使用是一个很复杂消耗很高的操作,对方法区的垃圾回收性能很低
JDK8以后运行时数据区变化不大,主要变化在执行引擎的GC垃圾回收器上
方法区的内部结构
方法区主要存储已经被虚拟机加载的类型信息[域信息、方法信息]、运行时常量池、静态变量、即时编译器编译后的代码缓存,JDK8以后JVM规范要求字符串常量池和静态变量转移到堆中,类型信息、字段、方法、常量保存在本地内存的元空间中
类型信息
类的全限定类名
当前类的直接父类的全限定类名,接口和Object类没有父类
类型的修饰符
当前类实现的直接接口的有序列表、
域信息
域名称、域类型、域修饰符、域的声明顺序
方法信息
构造器
方法名称、返回值类型、参数的个数和类型的有序列表、方法的修饰符、方法的字节码、操作数栈的深度、局部变量表的长度
异常表
如果方法有异常,会在异常表中记录每个异常处理的开始位置[try的下一行]、结束位置[整个try语句块的最后一行],出现异常时的下一行即catch语句那行
类变量
静态变量和类关联在一起,随着类的加载而加载,逻辑上属于类数据,被所有实例对象共享,即使没有任何类实例被创建依然可以直接访问类变量[一个对应类型的空指针也可以直接访问指定的类变量而不会抛空指针异常]
使用final修饰的类变量被称为全局常量,全局常量在编译期间就被赋值,类变量在类加载期间的链接环节的准备阶段才分配内存赋默认值,在类加载的初始化环节才显示赋值
运行时常量池
字节码文件中的常量池加载到方法区以后成为运行时常量池,常量池表包含了各种字面量以及对类型、域和方法的符号引用,符号引用的格式为#数字,常量池相当于一张表,用于存放编译期间生成的各种字面量和符号引用,虚拟机指令根据符号引用去常量表中找到要使用的类名、方法名、参数类型、字面值等数据
每个类都会对应一个运行时常量池,类或者接口被加载到虚拟机以后就会创建维护一个对应的运行时常量池,池中的数据和数组一样通过索引进行访问,索引从01开始,到常量池数量减1结束;运行时常量池中存放明确的数字字面值,并将运行解析后才能获取的方法或者字段的真实引用替换原来的符号引用,因此运行时常量池相较于常量池因为动态替换符号引用具有动态性的特征,在类加载的解析阶段会替换一次,在程序执行过程中通过符号引用找到对应的类,但是发现类对象没有加载会加载对应的类并将符号引用替换成直接引用
通过JVM参数-XX:+PrintStringTableStatistics能配置打印字符串常量池中的统计信息
方法区的垃圾回收
JVM规范没有对方法区的实现做强制约束,一些简单的实现可以不对方法区进行垃圾回收以及碎片进行压缩管理;实际上也存在未实现或未完整实现方法区类型卸载的垃圾收集器比如JDK11的ZGC就不支持类卸载,但是HotSpot一直还是支持方法区的垃圾回收的
这种不支持方法区类卸载的垃圾回收器性能也会更高一些
方法区中类型的卸载条件非常苛刻,导致方法区的垃圾回收效果一般不咋明显;但是有时候方法区的垃圾回收又确实比较必要;以前Sun公司的Bug列表中很多严重的Bug就是由于低版本的HotSpot虚拟机未完全回收方法区导致的内存泄漏,就是不回收方法区还不行,但是回收大部分时间都做的无用工
大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP和OSGI这类频繁自定义类加载器的场景通常还确实需要JVM具备类型卸载能力来保证不会对方法区造成过大的内存压力
方法区主要回收的是常量池中废弃的常量和不再使用的类,运行时常量池主要存放字面量和符号引用两大类常量;字面量主要包含文本字符串,被final修饰的常量值等;符号引用属于编译原理方面的概念,包含类和接口的全限定类名、字段的名称和描述符、方法的名称和描述符
常量池的回收策略比较明确,主要常量没有被任何地方引用就可以被回收
类的回收比较麻烦,判断一个类不再使用的条件很苛刻,需要同时满足以下三个条件才允许当前类被回收,但是也不是像对象一样没有引用了就必然被回收,
该类的所有实例都已经被回收即堆中不存在该类及任何派生子类的实例,只要有一个实例存在,实例就会有一个指针指向当前类在方法区的类型信息
加载该类的加载器已经被回收,类对象记录了当前类是被哪个类加载器加载的,类加载也记录了加载过具体哪些类,这个条件除非是精心设计的可替换类加载器场景比如OSGI和JSP的重加载,否则一般是很难达成的,注意类加载器加载的所有类都卸载的情况下类加载器也会销毁
当前类的class对象没有在任何地方被引用过,即没有在任何地方使用反射来访问该类
为什么要使用PC寄存器存储字节码指令地址呢?
因为CPU不停切换执行各个线程来实现并行并发效果,当CPU切换回当前线程时需要明确从哪一条指令继续向下执行,JVM字节码解释器通过改变PC寄存器的值来明确将被执行的下一条字节码指令
PC寄存器为什么被设定为线程私有
因为线程上下文切换需要记录当前线程正在执行的字节码指令地址,如果程序计数器被设定为共享的,当线程抢到CPU时间片后程序计数器中保存的线程状态信息会被其他线程覆盖导致当前线程的执行被其他线程干扰
举例栈溢出情况
虚拟机栈的容量如果是固定的随着方法调用不停压榨栈帧最后由于虚拟机栈容量不足抛出StackOverflowError异常,通过JVM参数-Xss可以设置栈的大小;如果虚拟机栈设置了可自动扩容最后由于没有内存无法扩容会抛出OutOfMemoryError异常
只调整栈大小不一定能保证不会出现栈溢出,默认情况下虚拟机栈的大小约1M,不管虚拟机栈多大,错误死循环的递归调用一定会导致栈溢出,虚拟机栈不涉及垃圾回收问题
虚拟机栈也不是越大越好,大了也可能只是延缓发生栈溢出的时间或者频率;而且太大都是无效空间反而会挤占其他内存空间
方法中定义的局部变量是否都是线程安全的
StringBuffer中的所有方法都添加了synchronized同步锁,StringBuilder没有添加同步锁,局部变量
引用数据类型也可能被多个线程访问因此局部变量不一定是线程安全的,要具体情况具体分析,始终保证单个线程对实例的原子性操作就是线程安全的
简述一下永久代
永久代是否会发生垃圾回收
为什么使用元空间替换永久代
Eden和Survivor比例如何分配
JVM规范中指出默认情况下伊甸园区与单个幸存者区的比例是8:1,但实际上不是8:1:1,我的显示是6:1;关于这点网上有些帖子说还可能受JVM的自适应机制影响,通过JVM参数-XX:-UseAdaptivePolicy可以关闭自适应机制[开启内存分配自适应还可能导致两个幸存者区不一样大],-Use的-表示不使用,如果替换成+表示使用;但是实际配置关闭自适应机制以后没有实现对应效果还是原样;要想完全自定义伊甸园区和幸存者区的比例需要使用JVM参数-XX:SurvivorRatio=8来自定义设置伊甸园区是单个幸存者区容量的8倍
HotSpot为什么要分为新生代和老年代
JVM内存模型,重点重排序, 内存屏障, happen-before, 主内存, 工作内存,直接内存
直接内存:本地内存就是直接内存,JDK8以后的元空间就用的本地内存;直接内存是在Java堆外直接向系统申请的内存区间;直接内存来源于NIO,通过基于直接内存的DirectByteBuffer实现操作系统和JVM都能直接访问到一块内存地址减少一次用户态到内核态的转换,减少一次数据拷贝;NIO在JDK1.4被引入,在JDK1.7引入NIO2即AIO,同时还引入了Files和Path等对NIO增强的API,JVM没有权限释放直接内存,底层是依靠Unsafe的方法通过操作系统来释放的;访问物理内存上的文件使用直接内存比堆的速度更快,JVM进程代表的应用程序从JVM内存中读取数据,JVM内存中的数据需要从内核地址空间拷贝,内核地址空间的数据需要从物理磁盘上读取;通过JVM进程向磁盘写入数据时也需要先将数据写入到用户态即用户地址空间,然后将数据复制到内核态即本地物理空间的内存上,再由操作系统将数据写出到物理磁盘上;相较于JVM和操作系统都能访问的直接内存多了一次数据从用户态向内核态的拷贝,性能更低;直接内存的开辟回收成本高,直接内存不受JVM管理,监控管理起来有难度,dump文件也不会有相关记录;直接内存读写性能高,在频繁读写的场合下会更多的考虑使用直接内存;Java中使用直接内存比较多的就是元空间和NIO
直接内存大小可以通过JVM参数MaxDirectMemorySize设置,如果不指定,默认与堆的最大值-Xmx的参数值一致;注意这里的直接内存大小不包括元空间,是JVM能通过比如NIO访问的直接内存大小,老师后来提到元数据区、直接内存是本地内存中互斥的两个部分,JVM进程占用的内存为堆内存加上元空间加上直接内存的总和
类加载与类卸载过程
概念:
类加器子系统负责从文件系统或者网络中将一个或多个字节码文件以二进制流的方式加载到内存结构中初始化成一个或多个Class实例[元数据模板,通过元数据模板的构造器就能在堆空间中创建单个或多个对应类对象,通过Class对象的getClassLoader()方法可以获取负责该过程的类加载器对象,通过对象的getClass()方法能获取到对应的Class对象],除了该Class实例外,方法区中还会存放运行时常量池信息[需要用的常量池加载到内存中就称为运行时常量池],字符串字面值和数字常量
字节码文件在文件头有一个特定的模数标识cafebaby,该模数会参与链接阶段的验证
类加载器只负责字节码文件的加载,字节码文件是否可以被执行是由执行引擎决定的
类加载包含加载、链接和初始化三个环节
类加载过程
当前类HelloLoader是否装载,已装载直接进入链接流程
没有装载使用类加载器进行装载[自定义类使用应用类加载器装载],如果字节码文件不是一个合法的字节码文件,类加载器加载的过程中会抛出异常,加载成功再内存中生成元数据模板即对应Class实例
有了Class对象执行链接步骤
初始化对象实例

加载环节
加载过程:
通过一个类的全限定类名从物理磁盘或者网络获取该类的二进制字节流
将字节流代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表该类的java.lang.Class对象,该对象作为方法区中该类的各种数据访问入口
被加载的字节码文件来源
本地文件系统
从网络中获取,典型应用就是Web Applet
从zip压缩包中读取,这也是jar、war压缩格式的读取基础[jar包war包解压后都是字节码文件]
运行时通过计算生成,典型应用就是动态代理技术java.lang.reflect.Proxy
由其他文件生成,典型应用就是JSP应用
从专有数据库中提取[比较少见]
从加密文件中解密获取,是一种防止字节码文件被反编译的保护措施[比如将.apk格式替换成.zip格式解压就能获取字节码文件,对字节码文件进行反编译就能盗版一个软件或者寻找软件漏洞,因此一般都会对字节码文件进行加密防止我们这种人反编译字节码,真正运行的时候会自动对加密后的字节码文件进行一个解密操作]
链接环节
链接过程
验证:通过验证文件格式、元数据验证、字节码验证、符号引用验证四种验证确保Class文件的字节流信息符合当前虚拟机要求,保证被加载类正确且不会危害虚拟机自身安全,验证不通过报verify error
准备:在方法区中为所有类变量[类变量是被static修饰的变量]分配内存,将所有非常量的类变量设置为默认初始值即零值,显示初始化的类变量在初始化环节设置指定值;但是注意被final修饰的类变量即常量准备阶段会直接赋值指定值;准备阶段不会分配初始化实例变量,实例变量随对象创建一起被分配到堆区
解析:将常量池内的类、方法、接口、属性的符号引用转换为直接引用[符号引用理解为占位符,直接引用是目标对象的内存地址,这一步就是建立虚方法表的过程],解析一般会在初始化环节完成后再执行
初始化环节
初始化环节就是执行类构造器方法<clinit>()的过程,这个<clinit>()方法不同于类的构造器,是javac编译器收集类中所有类变量的赋值动作和静态代码块中的语句合并自动得到对应字节码的<clinit>方法[<clinit>方法在字节码文件的Methods下的<clinit>下的Code可以看到对应的赋值语句和静态代码快语句对应的字节码指令]
类在第一次执行实例化对象时才会执行初始化环节,暂时不清楚整个类加载过程是否在第一次实例化对象时才会执行,因此一个类的静态代码块只有在第一次实例化该类对应对象时才会被执行
如果一个类没有类变量或者静态代码块,编译生成的字节码文件中不会有<clinit>()方法
如果一个类有父类,JVM会保证子类<clinit>()执行前父类的<clinit>()方法已经执行
<clinit>方法中的代码不管是定义中的赋值语句还是静态代码块中的语句都是自上而下按顺序进行显式赋值,这些类变量在链接环节已经分配了内存,因此即使在源文件中静态代码块中的变量赋值语句在变量定义前面也能正常执行,只是静态代码块会先赋值,然后被变量定义处的赋值语句再次覆盖
非法前向引用:在静态代码块后面定义的类变量只能在静态代码块中赋值,但是不能对在静态代码块中对该变量进行调用比如System.out.println(变量),如果在静态代码块前面定义的变量则不存在该问题
类的构造器对应的是<init>()方法[<init>方法在字节码文件的Methods下的<init>下的Code可以看到构造方法对应的字节码指令]
JDK8使用元空间即直接内存缓存已经加载完毕的Class对象,虚拟机类加载时始终只会执行一次<clinit>()方法保证类只被加载一次,虚拟机会给一个类的<clinit>()方法在多线程下加锁[类在第一次被实例化时初始化],一个类在初始化的时候其他也在实例化该类的对象的线程都得等待<clinit>()方法执行完毕,如果我们在静态代码块中写的代码如果导致<clinit>()方法无法结束,会导致其他等待实例化该类对象的线程全部无限时阻塞等待且没有任何提示信息
类加载初始化环节的执行条件[对类主动使用会执行初始化环节,被动使用不会执行初始化环节]
对类主动使用的七种情况[除了这七种情况其他都是被动使用]
创建类实例
访问某个类或者接口的类变量或者对该类变量赋值
调用类的静态方法
通过反射Class.forName("全限定类名")主动加载类对象
初始化一个类的子类[加载一个类前会首先保证加载该类的所有父类]
JVM启动时被标明为启动类的类
JDK7开始提供的动态语言支持
类加载器概念,类加载器举例
类加载器简述:类加载器时JVM执行类加载机制的前提,类加载器负责通过各种方式将类的二进制数据流读入JVM内部并将其转换为一个与目标类对应的Class对象实例交给JVM进行链接和初始化操作,一个类是否可以运行不归类加载器管而由执行引擎决定,JVM支持引导型类加载器和自定义类加载器,JVM规范将所有直接或间接继承于抽象类ClassLoader的累加载器都划分为自定义类加载器,即JDK提供的扩展类加载器ExtClassLoader和系统类加载器AppClassLoader都间接继承自ClassLoader属于自定义类加载器
类加载器在JDK1.0就出现了,那时只是单纯为了满足Java Applet即Java小程序研发出来的,如今类加载器在OSGI即热部署和热代码替换、字节码加解密领域大放异彩,类加载器在最初设计的时候就没有被绑定在JVM内部,不仅提供现成的类加载器实例,还允许用户自定义类加载并在系统中进行使用
引导类加载器、扩展类加载器、系统类加载器、用户自定义类加载器在逻辑上依次构成了上下级关系,通过ClassLoader.getSystemClassLoader()可以获取Launcher$AppClassLoader,通过appClassLoader.getParent()可以获取extClassLoader,通过extClassLoader.getParent()尝试获取bootstrapClassLoader会返回null,因为设定上就不让用户获取到引导类加载器
Bootstrap Class Loader是用C++实现的,ExtClassLoader和AppClassLoader都是用Java实现的
所有的类都是由类加载器加载的,用户自定义的类默认是通过单例appClassLoader来加载,Java的核心类库都是使用引导类加载器加载
方法区的类信息会记录当前类是被哪个类加载器加载的,类加载器也会记录加载过哪些类,一旦类加载的加载过的类对象都被销毁了,相应的类加载器也会销毁
JVM将字节码文件加载到内存的加载方式分类[实际开发中一般都是两种方式混用]
显示加载
概念:在Java代码中通过显示调用类加载方法如Class.forName(name);、class.getClassLoader().loadClass(name);或者ClassLoader.getSystemClassLoader().loadClass(name);使用类加载器ClassLoader加载字节码文件
隐式加载
概念:不通过显示加载方式加载的字节码文件都是隐式加载,通过JVM自动控制加载过程,比如一个类引用了另一个类的对象,额外引用的类如果还没有被加载就会通过JVM自动加载到内存中
系统需要支持类的动态加载或者需要对编译后的字节码文件进行加密解密操作时需要和类加载器打交道,开发者也可能需要自定义类加载器来重新定义类的加载规则来实现一些自定义的处理逻辑
比如自定义类加载器就可以不遵循双亲委派模型来避免双亲委派机制的劣势
类加载器的基本特征
双亲委派模型是JDK1.2加入的类加载器机制,一开始类加载器并不遵守该机制。此外也不是所有的类加载器都遵守该模型,启动类加载器可能也会加载用户代码,JDK提供的标准API即使提供了默认的参考实现,但是仍然有可能需要用户来提供自己的实现,比如Java中的JNDI、JDBC、文件系统、Cipher等,都是利用JDK内部的ServiceProvider/ServiceLoader机制来实现的,这些情况不会使用双亲委派模型来加载而是使用上下文加载器加载
可见性:子类加载器可以访问父类加载器加载的类,但是父类加载器不能访问子类加载器加载的类,基于这个可见性我们可以使用类加载器去实现容器的逻辑
单一性:父类加载器的加载类的结果对子类加载器是可见的,只要父类加载器成功加载过一个类,子类就不会进行重复加载;但是像自定义类加载器这种可以创建多个类加载器实例,多个实例之间因为看不见对方的加载结果,同一个类仍然可能被同一种加载器类型的不同实例加载多次
类的唯一性
同一个虚拟机上使用同一个类加载器加载同一个字节码文件产生的类是唯一的,但是只要加载同一个字节码文件的类加载器不同,产生的类就不是相等的类,而保证一个字节码文件只会被同一个类加载器加载的机制是双亲委派机制。
每个类加载器都有一个独立的类名称命名空间
命名空间有该类加载器加载的类和其所有父类加载器加载的类组成
同一个命名空间中不会出现全类名相同的两个类;不同命名空间中完全可能出现全类名相同的两个类,在大型应用中也会借助该特性来运行同一个类的不同版本,比如在Tomcat中让同一个工程通过不同类加载器的加载实现工程的隔离,在实际生产中应用比较广泛
用户自定义的类加载器可以通过UserClassLoader loader = new UserClassLoader("D;\\code\workspace_idea5\\JVMDemo\\src\\");指定类加载器的要加载的字节码文件存放目录[不同的类加载器的加载目录是不一样的,系统类加载器的默认加载目录是out\\工程目录\\模块目录下],并通过Class clazz = loader.findClass("com.earl.mall.Product");加载指定全类名的字节码文件生成对应的class实例
用户自定义的类加载器如果没有设计为单例模式可以创建多个实例,那么每个实例都是一个全新的类加载器,此时使用不同的类加载器实例加载同一个字节码文件生成的class对象是不同的,此时同一个类就被加载了多次而且这些class对象都是不同的,这些class实例指向的方法区的Product类模板结构也是不同的,这些class实例通过getClassLoader()获取的类加载器都是UserClassLoader的不同实例,都不是同一个类加载器;在Tomcat中就能借助这个点实现应用的隔离
如果运行程序时,程序中使用指定类加载器加载某个类,但是字节码文件又不在该类加载器的加载目录下会抛ClassNotFoundException异常
类加载器分类
JVM规范将类加载器分为引导类加载器和自定义类加载器,即使是HotSpot自带的扩展类加载器和应用类加载器也和开发者自定义的类加载器一样被统称为自定义类加载器,凡是使用Java语言实现派生于抽象类ClassLoader的类加载器都被划分为自定义类加载器
引导类加载器[启动类加载器]是扩展类加载器的逻辑父加载器,扩展类加载器是应用类加载器[系统类加载器]的逻辑父加载器,应用类加载器是所有用户自定义类加载器的逻辑父加载器,每种父类加载器都可以通过子类加载器的getParent()获取,只是引导类加载器无法被获取,调用该方法时会返回null,这个父只是指逻辑上的父子关系,和Java中的继承关系无关,只是一种包含关系指在子类加载器中包含着父加载器的字段引用
实际设计是在ClassLoader中定义了parent字段,类加载器的构造器传参父类加载器并为字段赋值,后续继承ClassLoader都通过super(parent)调用ClassLoader中的单参构造方法
引导类加载器
引导类加载器使用C和C++实现嵌套在JVM的内部,负责加载Java的核心类库[JAVA_HOME/jre/lib/rt.jar|resources.jar或者sun.boot.class.path都是核心类库],用于加载JVM自身需要的类,引导类加载器只加载包名为java、javax、sun打头的类
引导类加载器加载扩展和应用类加载器,并将引导类加载器指定为二者的父类加载器
引导类加载器没有继承自java.lang.ClassLoader,也没有父类加载器
通过URL[] urls = sun.misc.Launcher.getbootstrapClassPath().getURLS();能获取引导类加载器负责加载的所有类的目录列表,通过url.toExternalForm()可以获取引导类加载器加载目录的绝对路径
引导类加载器用户无法获取,任何获取引导类加载器的尝试最后都会获得null
扩展类加载器
扩展类加载器ExtClassLoader是Launcher类的内部类,间接继承自抽象父类ClassLoader,负责加载JAVA_HOME/jre/lib/ext子目录即扩展目录下的类库,如果用户创建的jar包放在该扩展目录下,jar包中的类也会自动由扩展类加载器加载,父类加载器为启动类加载器
通过String extDirs = System.getProperty("java.ext.dirs");能够获取到扩展类加载器加载目录列表以分号分隔的拼接字符串,一般就两个目录JAVA_HOME/jre/lib/ext和C:\WINDOWS\Sun\Java\lib\ext
应用/系统类加载器
应用类加载器AppClassLoader是Launcher类[Lancher类是JVM的入口应用]的内部类,间接继承自抽象父类ClassLoader[AppClassLoader和ExtClassLoader一样直接继承自URLClassLoader],负责加载环境变量classpath即用户自定义类或者系统属性java.class.path指定路径下的类库,是用户自定义类的默认类加载器,是用户自定义类加载器的默认父加载器,即使用户自定义类直接继承ClassLoader,其父类加载器还是系统类加载器;系统类加载器是使用频率最高的类加载器
在Launcher类的构造器中先调用ExtClassLoader.getExtClassLoader()创建扩展类加载器,然后又调用AppClassLoader.getAppClassLoader()传参扩展类加载器将扩展类加载器作为AppClassLoader的父加载器创建系统类加载器,给具体的parent字段赋值是通过super(parent)调用顶级父类ClassLoader的构造器实现的,创建完系统类加载器以后会通过Thread.currentThread().setContextClassLoader(this.loader);将系统类加载器设置为默认的线程上下文类加载器
扩展类加载器在调用构造器时传递的第二个参数parent值是null,用来表示引导类加载器
ClassLoader.getSystemClassLoader()获取的就是系统类加载器
用户自定义类加载器
自定义类加载器应用场景:
隔离加载类[不同框架的类、框架和用户的类的全限定类名可能相同,大型主流框架一般都会自定义类加载器将框架和用户代码加载到不同环境中避免全限定类名相同的类发生冲突,类仲裁如果发现两个类的全类名相同会出现类冲突的问题,不解决会抛出异常]
tomcat这类Web应用服务器,内部也自定义了好几种类加载器,用于隔离同一个Web应用服务器上的不同应用程序
利用类加载器的命名空间提供类似容器、模块化的功能实现类似进程内隔离的效果,比如,两个依赖于同一个类库的不同版本的模块,如果分别被不同的类加载器或者容器加载,就可以互不干扰,在类的隔离方面集大成者有JavaEE、OSGI和JPMS等
修改类的加载方式[除了BootstrapLoader其他类加载器不是必须使用,用户可以根据自己需求自定义类加载器来替代扩展类加载器和应用类加载器]
扩展加载源[除了上述字节码获取方式用户还想从数据库网络机顶盒等其他方式中获取字节码二进制信息流]
任意能获取字节流的方式都能扩展系统从各种数据源获取字节码二进制数据流的能力,而不是只能从本地文件系统获取字节码信息
防止源码泄露[java代码很容易被编译和篡改,可以通过自定义类加载器在加载过程中自动对字节码文件进行解密然后生成真正的字节码二进制信息流进行类加载防止源码泄漏]
自定义类加载器的步骤
继承ClassLoader,JDK1.2前继承ClassLoader需要重写loadClass()方法实现自定义类加载过程,JDK1.2以后不建议用户覆盖loadClass()方法,建议把自定义的类加载逻辑重写在findClass()方法中[将字节码文件以二进制流的形式写入并进行处理,如果是加密后的字节码文件需要先对字节流数据进行解密]
如果自定义类加载器没有太复杂的需求可以直接继承URLClassLoader类,避免用户去自己重写findClass(),自定义类加载器一般都要直接或者间接继承自ClassLoader
Java开发者通过自定义类加载器实现类库从本地或者网络动态加载是Java语言繁荣的关键因素之一,比如OSGI组件框架和Eclipse的插件机制都是通过类加载器实现的插件机制,能够实现为应用程序提供动态增加新功能,实现热部署无需重新打包发布应用程序
自定义类加载器还可以实现应用的隔离,像tomcat、spring等中间件组件框架都在内部实现自定义类加载器隔离不同的组件模块,这方面要比C/C++好太多了,要想不修改C/C++程序就能为应用添加新功能几乎是不可能实现的
自定义类加载器的实现方式
1️⃣自定义类加载器继承自ClassLoader重写loadClass()方法指定类加载的完整逻辑,通过重写该方法可以避免自定义类加载器使用双亲委派机制
2️⃣自定义类加载器继承自ClassLoader重写findClass()方法指定字节码文件的加载以及转换成二进制流的逻辑,该方法被loadClass()方法调用,可以保证自定义类加载器仍然遵守双亲委派机制
自定义类加载器可以通过重写loadClass()方法抹去双亲委派机制,此时是否能用自定义的类加载器加载核心API呢?还是不行,因为JDK还为核心类库提供了一层保护机制,不管是自定义类加载器还是JDK提供的类加载器,最后都要调用JDK提供的本地方法defineClass(),该方法会调用preDefineClass()提供对JDK核心类库的保护
自定义类加载器的父类加载器都是系统类加载器
代码实现
注意事项
ClassLoader中的loadClass方法调用findClass方法,loadClass中主要实现了双亲委派机制,findClass中定义了字节码二进制信息流的查找加载方式,并调用defineClass本地方法传参字节码二进制流获取class对象
所有的自定义类加载器都应该继承ClassLoader,开发者可以根据需要选择重写loadClass方法或者defineClass方法,JVM建议重写findClass不要重写loadClass避免破坏双亲委派模型破坏原有结构造成系统容易出现问题,开发者定义好自定义类加载器后只需要调用对应的loadClass方法就行,既能保证双亲委派模型,也能保证自定义字节码二进制信息流的自定义加载方式
所有类的加载包括JDK核心类库的加载都使用的ClassLoader的loadClass方法
实现步骤
创建一个自定义类加载器继承自ClassLoader
声明一个字段指定类加载器的类加载目录
重写findClass(String name)方法定义根据传入的全类名找到字节码文件并加载字节码二进制信息流到JVM中,调用本地方法defineClass传入字节码二进制数组获取class对象并返回
[示例代码]
xxxxxxxxxxpackage io.renren.classloader;
import java.io.BufferedInputStream;import java.io.ByteArrayOutputStream;import java.io.FileInputStream;import java.io.IOException;
public class CusClassLoader extends ClassLoader { private String loadPath;
public CusClassLoader(String loadPath) { this.loadPath = loadPath; }
public CusClassLoader(ClassLoader parent, String loadPath) { super(parent); this.loadPath = loadPath; }
public static void main(String[] args) { CusClassLoader loader = new CusClassLoader("d:/"); try { Class<?> clazz = loader.loadClass("com.earl.mall.Product"); System.out.println("加载此类的类加载器为: " + clazz.getClassLoader().getClass().getName());//加载此类的类加载器为: io.renren.classloader.CusClassLoader } catch (ClassNotFoundException e) { e.printStackTrace(); } }
protected Class<?> findClass(String name) throws ClassNotFoundException { BufferedInputStream bis = null; ByteArrayOutputStream baos = null; name = name.replaceAll("\\.", "/"); try { String byteCodeFilePath = loadPath.concat(name).concat(".class"); bis = new BufferedInputStream(new FileInputStream(byteCodeFilePath)); //准备字节数组输出流ByteArrayOutputStream将输入流中的数据通过输出流写出到内存中输出流实例中的byte数组中 baos = new ByteArrayOutputStream(); int len; byte[] buffer = new byte[1024]; while ((len = bis.read(buffer)) != -1) { //将字节码字节数组写到ByteArrayOutputStream实例中的数据数组中 baos.write(buffer, 0, len); } //将字节码文件字节数据封装成byte数组 byte[] byteCode = baos.toByteArray(); return defineClass(null, byteCode, 0, byteCode.length); } catch (IOException e) { e.printStackTrace(); } finally { try { if (baos != null) baos.close(); } catch (IOException e) { e.printStackTrace(); } try { if (bis != null) bis.close(); } catch (IOException e) { e.printStackTrace(); } } return null; }}注意涉及到两个类型做类型转换时,只有两个类型都被同一个类加载器加载才能进行类型转换,否则类型转换时会发生异常
JDK9以后系统类加载器变成了ClassLoaders的内部类了,系统类加载器的父类加载器变成了ClassLoaders$PlatformClassLoader而不再是扩展类加载器了,平台类加载器的父类加载器是引导类加载器,因为JDK9新特性模块化系统导致类加载器也有了一些新变化
扩展机制被移除,扩展类加载器被重新命名为平台类加载器,可以通过ClassLoader.getPlatformClassLoader()来获取
JDK9是基于模块化构建的,将原来的tr.jar、tools.jar拆分成数十个JMOD文件,Java类库天然满足了可扩展的需求,无需再保留JAVA_HOME\lib\ext目录,此前使用JAVA_HOME\lib\ext目录或者java.ext.dirs系统变量来扩展JDK的功能已经没有继续存在的价值了
平台了加载器和系统类加载器都不再继承自java.net.URLClassLoader,启动类加载器BootClassLoader、平台类加载器、系统类加载器全部继承自jdk.internal.loader.BuiltinClassLoader,BuiltinClassLoader继承自SecureClassLoader,JDK9以后得版本自定义类加载器就不能继承自URLClassLoader了;引导类加载器也变成了JVM和Java类库共同协作实现的类加载器,但是为了向下兼容,尝试获取启动类加载器仍然会返回null
类加载器有了名称,新增了getName()方法来获取类加载器的名称,该方法主要用于与类加载器的调试相关场景下
双亲委派机制也发生了一些变化,Java代码被划分成了若干模块,不同的模块由不同的类加载器进行加载,因此在进行类加载在把当前类委派给父类加载器加载前先判断当前类是否能归属到某一个系统模块中,如果是就找哪一个类加载器负责加载该模块并直接将当前类交给对应类加载器进行加载
获取类加载器的几种方式
通过类对象class.getClassLoader()获取指定类对象类加载的类加载器
数组比如String[]的类型是[Llang.java.String;,在JVM中没有数组这种类型,实际上还是JVM加载String这种类型然后动态地创建几个内存连续的相同类型元素,但是数组对象arr仍然能调用arr.getClass().getClassLoader();,只是此时的结果是null,数组类的类加载器和数组中元素类型的类加载器是一样的,比如String类型元素数组的类加载器是引导类加载器,如果数据的元素类型是基本数据类型[基本数据类型是虚拟机预设的不需要类加载器加载],数组类是没有类加载器的[这种情况下也是返回null]
通过当前线程上下文Thread.currentThread().getContextClassLoader()获取当前代码所在类的负责加载的类加载器,默认情况下上下文类加载器就是系统类加载器
通过ClassLoader.getSystemClassLoader().getParent()可以依次获取到应用类加载器和扩展类加载器
类加载器的继承结构
ClassLoader是一个没有抽象方法的抽象类,其中定义了loadClass(String)、resolveClass(Class<?>)、findClass(String)、defineClass(byte[],int,int)比较重要的几个方法,设置成抽象类只是让其不能实例化,但是其中有方法体为空的方法比如findClass(String)
功能是通过类的全类名去加载字节码二进制流
public final ClassLoader getParent():获取当前类加载器的父类加载器
public Class<?> loadClass(String name) throws ClassNotFoundException:基于双亲委派机制加载全类名为name的字节码文件或者网络传输的二进制流数据,返回对应类的Class实例,如果找不到指定类则抛ClassNotFoundException,该方法的实现就是双亲委派机制的实现
该方法就是ClassLoader.getSystemLoader().loadClass("com.earl.mall.Product")时的逻辑
xxxxxxxxxxclassLoader.loadClass(name)public Class<?> loadClass(String name) throws ClassNotFoundException { return loadClass(name, false);1️⃣ }
1️⃣classLoader.loadClass(name, false)protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{//入参resolve为true表示加载类的同时进行解析环节,该参数为false表示不需要解析 synchronized (getClassLoadingLock(name)) {//为加载类操作上锁,保证类只会被加载一次 Class<?> c = findLoadedClass(name);//首先去缓存即类加载器的命名空间中检查是否已经加载过同名类,如果已经存在直接返回对应`class`对象无需加载,否则返回null if (c == null) {//没有被加载过进入if语句块进行加载 long t0 = System.nanoTime();//获取系统时间 try { //如果当前类加载器不是引导类加载器就递归调用父类加载器的loadClass(name, false)方法让父类加载器来进行类加载,这里的递归会先找到引导类加载器,引导类加载器加载不了才会在后续返回每次递归调用的后序遍历时依次从上 if (parent != null) { c = parent.loadClass(name, false); } else { //如果扩展类加载器的父类加载器是引导类加载器就检查引导类加载器是否能够加载当前类 c = findBootstrapClassOrNull(name);//如果引导类加载器不能加载会返回null } } catch (ClassNotFoundException e) {
}
if (c == null) { //如果当前类加载器的父类加载器不能加载当前类调用findClass(name)判断当前类加载器是否能加载当前类,如果还是不能加载就返回上一次子类加载器调用c = parent.loadClass(name, false);处返回null表示当前父类无法加载当前类,子类加载器在重复这个过程判断自身是否能加载当前类,说白了就是递归的后序遍历判断当前类加载器是否能加载当前类,这就是双亲委派机制的实现方式 long t1 = System.nanoTime(); c = findClass(name);
// thi is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; }}protected Class<?> findClass(String name) throws ClassNotFoundException:ClassLoader的findClass(name)方法没有方法体,如果不重写就进行调用会抛出ClassNotFoundException(name)异常,该方法在子类URLClassLoader中做了重写,在扩展类加载器和系统类加载器中都没有重写该方法,即扩展类加载器和系统类加载器都使用URLClassLoader重写的findClass(name)逻辑;只能在同一个包、子包或者子类中调用
功能:该方法会在检查完父类加载器是否能加载当前类后被loadClass()方法调用检查当前类加载器能否加载当前类,实际上loadClass()主要实现双亲委派机制,findClass()才是判断当前类是否能被当前类加载器加载并进行加载的方法
在JDK1.2以前还没有双亲委派机制自定义类加载器总是会重写loadClass方法实现自定义的类加载逻辑,JDK1.2以后已经不再建议开发者去覆盖loadClass()方法而是将类加载的逻辑写在findClass()方法中来保证自定义的类加载器也符合双亲委派机制
findClass()方法通常和defineClass()方法一起使用,自定义类加载器通过重写findClass()方法自定义类加载规则,取得加载器的字节码二进制流后调用defineClass()方法生成对应的类的class实例;一般自定义类加载器都是重写findClass()方法来编写字节码二进制流的加载规则,然后通过调用defineClass()方法解析二进制码生成class对象
[uRLClassLoader.findClass(name)]
xxxxxxxxxxprotected Class<?> findClass(final String name) throws ClassNotFoundException{ final Class<?> result; try { result = AccessController.doPrivileged( new PrivilegedExceptionAction<Class<?>>() { public Class<?> run() throws ClassNotFoundException { String path = name.replace('.', '/').concat(".class");//拼出字节码文件的相对路径 Resource res = ucp.getResource(path, false);//通过字节码文件的相对路径获取字节码数据 if (res != null) { try { return defineClass(name, res);//通过字节码二进制数据创建class实例并直接返回 } catch (IOException e) { throw new ClassNotFoundException(name, e); } } else { return null; } } }, acc); } catch (java.security.PrivilegedActionException pae) { throw (ClassNotFoundException) pae.getException(); } if (result == null) { throw new ClassNotFoundException(name); } return result;}[uRLClassLoader.defineClass(name, res)]
xxxxxxxxxxprivate Class<?> defineClass(String name, Resource res) throws IOException { long t0 = System.nanoTime(); int i = name.lastIndexOf('.'); URL url = res.getCodeSourceURL(); if (i != -1) { String pkgname = name.substring(0, i); // Check if package already loaded. Manifest man = res.getManifest(); definePackageInternal(pkgname, man, url); } // Now read the class bytes and define the class java.nio.ByteBuffer bb = res.getByteBuffer(); if (bb != null) { // Use (direct) ByteBuffer: CodeSigner[] signers = res.getCodeSigners(); CodeSource cs = new CodeSource(url, signers); sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0); return defineClass(name, bb, cs); } else { byte[] b = res.getBytes(); // must read certificates AFTER reading bytes. CodeSigner[] signers = res.getCodeSigners(); CodeSource cs = new CodeSource(url, signers); sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0); return defineClass(name, b, 0, b.length, cs); }}protected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError
功能:传入一个字节码二进制流解析得到JVM能识别的Class对象,off是从第几个字节开始读,len是读取字节的长度,通过该方法可以把字节码文件实例化为class对象,我们也可以从网络中获取字节码二进制流使用该方法创建class对象
xxxxxxxxxxprotected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError{ return defineClass(name, b, off, len, null);}
protected final Class<?> defineClass(String name, byte[] b, int off, int len, ProtectionDomain protectionDomain) throws ClassFormatError{ protectionDomain = preDefineClass(name, protectionDomain); String source = defineClassSourceLocation(protectionDomain); Class<?> c = defineClass1(name, b, off, len, protectionDomain, source); postDefineClass(c, protectionDomain); return c;}
private native Class<?> defineClass1(String name, byte[] b, int off, int len, ProtectionDomain pd, String source);protected final void resolveClass(Class<?> clazz)
该方法可以手动在Class对象创建完成时执行解析操作
protected final Class<?> findLoadedClass(String name)
查找并返回名称为name已经被加载过的class实例,如果没找到返回null
SecureClassLoader继承自ClassLoader,URLClassLoader继承自SecureClasssLoader
URLClassLoader重写了ClassLoader中的findClass(name)、findResource()等方法,在扩展类加载器和系统类加载器中都没有重写该方法,即扩展类加载器和系统类加载器都使用URLClassLoader重写的findClass(name)逻辑,此外还增加了URLClassPath类协助获取字节码流等功能,开发者在自定义类加载器时如果没有太复杂的业务需要,可以直接继承自URLClassLoader避免自己去编写findClass()方法以及其他获取字节码流的方法,更便捷地自定义类加载器
SecureClassLoader中增加了对代码源的位置和证书的验证以及对字节码的访问权限验证的方法,开发者一般只会和他的子类URLClassLoader打交道
扩展类加载器和系统类加载器都继承自URIClassLoader
在系统类加载器中对ClassLoader的loadClass(String,boolean)方法进行了重写,这里面只是增加了一些判断的操作,判断之后最终仍然通过super.loadClass()调用ClassLoader实现的loadClass()方法
这两个类加载器因为都调用的是ClassLoader实现的loadClass()方法,因此都遵循双亲委派机制
Class.forName()和ClassLoader.loadClass的区别
Class.forName()是一个静态方法,传参类的全限定类名返回一个Class对象,加载类的同时会进行类的初始化阶段
ClassLoader.loadClass()是一个实例方法,需要一个类加载器实例来调用,该方法只会加载一个类,但是不会执行类的初始化阶段并调用<clinit>()方法,只有该类在第一次主动使用时才会进行初始化,而且也不会执行类的解析环节,但是可以指定类的加载器
简述类的生命周期
基本数据类型由虚拟机预先定义[不需要进行类加载],引用数据类型需要类[类是统称,除了类还可以指代接口、注解、枚举类]的加载
使用类的时候会先判断当前类是否已经被类加载过,如果还没有加载过就需要使用对应的类加载器进行加载,加载过程中会对字节码文件做验证操作,如果不是合法的字节码文件会抛出相关的异常;验证没问题就可以继续进行链接阶段的准备、解析对类变量进行默认赋值并将符号引用改成直接引用,然后在初始化阶段对类变量进行显示赋值;初始化完成类就被成功加载了,此时就会在方法区存放被加载类的模板,调用类的静态方法、创建类的实例或者调用类的成员变量都属于对类的使用环节,类使用完毕以后会对类进行卸载
有些类的生命周期和JVM一样是无法被卸载的,类被回收需要方法区进行GC,没有进行GC即使类使用结束也不会立即被回收,
类的生命周期中的七个阶段
加载
职责:将字节码文件加载到机器内存中,并在内存中构建Java类的原型--类模板对象,类模板对象就是Java类在JVM内存中的一个快照,保存着从字节码文件中解析出来的常量池、类字段、类方法等信息;JVM在运行期能通过类模板获取Java类中的任意信息,访问成员变量以及调用方法
反射机制就是基于JVM内存中的类模板
类加载器只和加载阶段有关,加载阶段结束,方法区的类模板结构和堆中的Class实例都会完成创建,类加载器和链接环节以及初始化环节没有关系,这两个环节由JVM负责
具体任务
在src目录开始根据类的全名获取类的二进制数据流
通过Class clazz = Class.forName("java.lang.String");可以手动加载类并获取对应的Class实例
解析二进制数据类为方法区的Java类模板
在堆中创建当前类的java.lang.Class实例作为方法区该类模板的各种数据的访问入口
Class实例是instanceKlass实例的一个镜像,是访问类型元数据的入口,也是实现反射的入口,通过class实例能访问类模板中的各种数据,可以把class对象狭隘地理解为指向类模板结构
Class类的构造器是私有的,只有JVM能够创建
通过Class对象可以通过反射获取当前类声明的所有方法、获取每个方法的修饰符、返回值类型、方法名、参数列表
获取二进制流的方式
磁盘上的字节码文件
读入jar、zip等归档数据包提取类文件
存在数据库中类的二进制数据
使用网络协议通过网络进行传输加载
运行时动态生成类的二进制信息
🔎:如果输入的数据不是字节码文件的格式会抛出ClassFormatError异常
数组类的加载
数组类本身不是由类加载器负责加载创建的,JVM运行时根据元素类型和数组维度可以直接创建,数组的元素类型如果是引用数据类型此时才会使用到类加载器加载对应元素的类
链接
验证
职责:保证加载的字节码是符合JVM规范的,验证包含格式检查、语义检查、字节码验证、符号引用验证四个环节
格式检查:包含魔数检查、版本检查、指令长度检查,格式检查会和加载阶段同时执行,格式检查成功后才会将类的二进制数据信息加载到方法区中;在方法区生成类模板以后才会进行后续三个验证环节
语义检查:检查字节码信息在语义上是否符合JVM规范,例如除Object外所有的类的属性表中都有指定父类、检查被final修饰的方法或者类没有被重写或者继承过、非抽象类是否实现了所有抽象方法或者接口方法、检查是否存在不兼容的方法[方法不同同名的情况下形参列表还相同、方法不能同时被abstract和final修饰]
字节码验证:对字节码流进行分析,判断字节码是否可以被正确执行;比如检查字节码执行过程中是否会跳转到一条不存在的指令、方法的调用和变量的赋值已经指令的调用是否传递了正确类型的参数
栈映射帧[StackMapTable]:用于检测在特定字节码处。局部变量表和操作数栈是否有正确的数据类型,该过程只能尽可能检查可以明显预知的问题,不能完全确定字节码可以被安全执行,没有通过该检查的类不会被虚拟机装载,通过了也不能证明这个类没有问题;StackMapTable在方法表的Code属性中的属性表的StackMapTable中
符号引用验证:符号引用验证在解析环节才会执行,用于校验符号引用对应的类或者方法是否确实存在,并且当前类有访问这些数据的权限,如果要使用的类在系统中找不到会抛出NoClassDefFoundError,如果一个方法在系统中找不到会抛出NoSuchMethodError
准备
职责:为静态变量分配内存并初始化为默认值,为字面量声明的常量进行显示赋值
相当于为类变量分配内存并赋值默认值时常量直接赋值最终值,因为常量不能被重复赋值不能像静态变量一样在解析环节再进行显示赋值
常量如果是对象实例比如static final String str = new String("123")在初始化阶段执行<clinit>()方法时才会被显示赋值,对应字节码中字段表中的字段也不会有属性ConstantValue,但是通过字面值声明的常量字段表中的对应字段有ConstantValue属性
准备阶段不会执行任何初始化代码
老师的结论有点啰嗦:使用static+final修饰且显示赋值中不涉及到方法或者构造器调用的基本数据类型或String类型的显示赋值在链接阶段的准备环节进行
解析
职责:将类、接口、字段和方法的符号引用转换为直接引用
类模板中有方法表,所有的方法都列在表中,需要调用一个类的方法时只要知道该方法在方法表中的偏移量就可以直接调用该方法,通过解析环节可以将符号引用转换为在对应类方法表中的位置,从而使得方法具备被实际调用的条件;要得到一个类、方法、字段在内存中的指针或者偏移量,对应类一定被加载完成;
在HotSpot中加载、验证、准备和初始化都会按顺序执行,但是解析一般会在初始化完成后再执行
Java中直接使用字符串常量是会在类的常量池中出现一个CONSTANT_String结构表示一个字符串常量,该常量会引用一个字符串字面量CONSTANT_utf8_info,JVM运行时常量池中会维护一个字符串拘留表[就是串池,JDK7开始移入了堆空间],会保留所有出现过的字符串常量并且保证没有重复项,以CONSTANT_String结构出现过的字符串都会在串池中创建对应的字符串对象
初始化
职责:初始化类的静态变量为静态变量进行显式赋值,在初始化阶段才会真正开始执行类中定义的Java程序代码[静态代码块、静态变量的显示赋值语句],初始化以前的步骤没问题说明类可以被顺利装载到系统中,初始化阶段最重要的工作是执行类的初始化<clinit>()方法,该方法由前端编译器生成并由JVM调用,是由常量的代码显示赋值语句、静态变量的赋值语句以及静态代码块合并产生
在加载一个类前虚拟机总会先加载其父类,因此父类的<clinit>()总是先于子类的<clinit>()之前被调用,对应的父类的静态方法总是先于子类的静态方法先执行
<clinit>()方法会合并有显示赋值语句的静态变量、采用代码而非字面量显示赋值的常量、静态代码块的代码,对于非静态字段、没有显示赋值的静态字段以及没有显示赋值以及使用字面量赋值的常量都不会参与生成<clinit>()方法
静态代码块是类的初始化代码块,接口中不能有静态代码块
<clinit>()的线性安全性问题
JVM会确保<clinit>()方法调用时的线程安全性,保证<clinit>()方法只被执行一次,<clinit>()方法的访问标志只有一个static,没有带synchronized,<clinit>()的锁是一个隐式的锁,如果<clinit>()方法没有明确的终止时间,可能导致多个线程阻塞导致死锁,这种死锁是很难发现的,因为我们看不到相应的锁信息,而且这种死锁无法通过jvisualvm看到
一个典型的场景是在A的静态代码块中去加载B,且在B的静态代码块中去加载A,如果两个类同时被两个线程加载会因为都在等对方加载完导致两个类的加载发生死锁,最终导致所有需要加载这两个类的线程全部阻塞等待,因此在一个类的静态代码块中加载另外一个类一定要特别小心
Java程序对类的使用分为主动使用和被动使用两种
一个类被主动使用<clinit>()方法才会被调用,被动使用不会调用<clinit>()方法,即一个类被成功加载不一定会执行初始化阶段
JVM规定类或者接口只会在首次使用时才会被装载,而且只有在主动使用的情况下加载类才会进行初始化阶段,被动使用的类通常会进行类加载但是不会经历初始化阶段
主动使用的情况
1️⃣使用new关键字、或者通过反射、克隆、反序列化创建一个类的实例
2️⃣使用字节码指令invokestatic调用类的静态方法
3️⃣使用字节码指令getstatic、putstatic访问或者赋值类或者接口的静态字段或者非字面量显示赋值的常量,注意使用字面量显示赋值的常量被访问不会触发类加载的初始化阶段
4️⃣使用java.lang.reflect包中的反射类的方法时,比如Class.forName(str)
5️⃣初始化子类时发现父类还没有初始化触发父类的初始化
这个规则不适用于接口,初始化一个类时并不会先初始化他实现的接口,初始化一个接口时也不会先初始化他的父接口;接口只有在程序首次使用接口中的静态字段或者访问非字面量显示赋值的常量时才会进行初始化
6️⃣一个接口定义了被default关键字修饰的默认方法,直接或者间接实现该接口的类初始化,在该类初始化以前会触发该接口初始化
7️⃣JVM启动时会通过引导类加载器初始化main()方法所在的类,主方法的执行将依次导致所需的类的加载、链接和初始化
8️⃣首次调用MethodHandle实例时需要初始化该MethodHandle指向的方法所在的类[即涉及解析REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄对应的类需要先进行初始化]
MethodHandle:这是反射包下的一个类
被动使用的情况
访问一个静态字段时只有真正声明该字段的类才会被初始化,比如通过子类引用访问父类中声明的静态变量不会导致子类初始化,但是子类仍然会被加载
定义引用类型数组不会触发该类的初始化
比如Parent[] parents = new Parent[10],parents的类型为[Lcom.earl.java.Parent数组类型,数组类型的父类也是Object,只是通过上述语句创建parents数组不会涉及Parent类的初始化,在调用Parents[0]=new Parent();的时候才会触发Parent类的初始化
使用在链接阶段的准备环节已经被字面值显示赋值的常量不会触发常量声明所在类或者接口的初始化
int CONSTANT=new Random().nextInt(10);实际上是一个非字面量显式赋值的常量[因为接口中的变量默认修饰符是public static final],在接口类加载时的初始化阶段通过<clinit>()方法进行赋值
调用ClassLoader类的loadClass()方法比如Class clazz = ClassLoader.getSystemClassLoader().loadClass("com.earl.mall.Product");加载一个类不会触发初始化阶段
注意Class.forName("com.earl.mall.Product")加载一个类会触发初始化阶段
使用
职责:开发人员可以在程序中访问和调用类的静态字段和静态方法,或者使用new关键字创建指定类的对象实例,创建对象实例以后可以通过对象去访问非静态字段和实例方法
卸载
职责:
一个Class对象总会引用它的类加载器,通过class.getClassLoader()方法可以获取到对应的类加载器,类加载器中也会维护一个集合来存放当前类加载器加载的类的Class对象,这种关系称为双向关联关系
对象实例可以通过getClass()方法获取对应类的class对象,即class对象会被类对应的所有实例引用
当一个Class对象没有被任何对象实例引用时,Class对象就会结束生命周期被回收,方法区的类模板数据也会被卸载从而结束类的生命周期,Class对象被销毁,方法区中的类模板结构因为没有被引用才会回收
类的回收比较麻烦,判断一个类不再使用的条件很苛刻,需要同时满足以下三个条件才允许当前类被回收,但是也不是像对象一样没有引用了就必然被回收,一个已经被加载的类被卸载的概率很小且就算卸载卸载的时间也不能确定,因此开发者开发系统功能时不应该把特定类型的卸载作为功能的假设前提
该类的所有实例都已经被回收即堆中不存在该类及任何派生子类的实例,只要有一个实例存在,实例就会有一个指针指向Class对象,Class对象指向当前类在方法区的类型信息
加载该类的加载器已经被回收,Class对象记录了当前类是被哪个类加载器加载的,类加载器中的集合也记录了加载过具体哪些类,这个条件除非是精心设计的可替换类加载器场景比如OSGI和JSP的重加载,否则一般是很难达成的,注意类加载器加载的所有类都卸载的情况下类加载器也会销毁
启动类加载器加载的类即Java的核心API在整个JVM运行期间都不会被卸载,启动类加载器本身也不可能被卸载,JVM规范和JLS规范[Java语言规范]都提到了这点
系统类加载器和扩展类加载器实例在JVM运行期间也不会被卸载,总是能被直接或者间接地访问到,因此被系统类加载器和扩展类加载器加载的类型也不太可能在JVM运行期间被卸载
开发者自定义的类加载器实例一般会采用缓存策略缓存起来提高系统性能,因此被用户自定义加载器加载的类型在JVM运行期间也几乎不太可能被卸载,这些类型只有在很简单的上下文环境中还要借助强制调用垃圾回收功能才能被卸载,因为垃圾回收的时间不确定因此即便这些类被卸载,卸载的时间也是无法确定的
当前类的class对象没有在任何地方被引用过,即没有在任何地方使用反射来访问该类,class对象可能被直接引用,或者被当前类的实例引用或者被类加载器引用
Class对象的引用全部断开后,如果再次需要使用对应类,会首先检查CLass对象是否还存在,如果存在则直接使用Class对象当前类不需要重新加载,如果Class对象已经不存在,JVM会重新加载当前类
🔎:其中验证、准备、解析三个环节统称为链接阶段,因此也可以说类的生命周期有五个阶段,加载-链接(验证-准备-解析)-初始化-使用-卸载
类文件结构
JVM中两个class对象是同一个类要求class对象的全限定类名完全一致且两个class对象的类加载器对象必须相同,
即使来自同个Class文件,只要类加载器实例不同,两个class对象就不相等;class对象的类加载器引用会作为类的信息保存在方法区中
类加载器可以打破双亲委派吗,怎么打破
双亲委派机制
双亲委派机制原理
1️⃣:如果一个类加载器收到类加载请求,该类加载器会首先委托其父类加载器去执行该类加载请求
2️⃣:如果父类加载器还存在父类加载器,则继续委托其父类加载器执行该类加载请求;最终会委托给引导类加载器
3️⃣:此时如果当前类加载器可以完成类加载任务就由当前类加载并直接返回,如果当前类无法完成该加载任务就由当前类加载器的子类加载器去尝试完成类加载任务并返回
例子:小孩拿着苹果问"妈妈你吃吗?",妈妈拿着苹果问奶奶"妈妈你吃吗?",奶奶说我吃就奶奶吃,奶奶说我不吃妈妈说我吃就妈妈吃,妈妈说我不吃就小孩吃
双亲委派模型是在类加载器的公共父类ClassLoad中的loadClass()方法中通过递归判断当前类是否被加载器过并找父类加载器直到引导类加载器,然后依次在返回递归调用前即递归的后序遍历挨个判断是否能被当前类加载器加载,能加载直接加载返回,如果不能加载就返回null,子类加载器再判断是否能加载,如果所有类加载器遍历了都无法加载则抛出ClassNotFoundException
双亲委派机制的代码实现逻辑
🔎:双亲委派机制在java.lang.ClassLoader的loadClass(String,boolean)方法中实现的,具体的实现逻辑如下
注意即使是核心API的类也是从系统类加载器依次向上找父类直到找到引导类加载器
在当前类加载器的命名空间中查找有无当前类的class对象,如果有直接返回
判断当前类加载器的父类加载器是否为null,不为null说明父类加载器不是引导类加载器,调用父类的loadClass(String,boolean)方法尝试让父类去加载当前类
如果当前类加载器的父类加载器为null即是引导类加载器,则调用findBootstrapClassOrNull(name)尝试让引导类加载器判断是否加载过并尝试进行加载
如果父类加载器无法成功加载当前类,则当前类加载器调用findClass(name)判断当前类加载器是否能进行加载,如果能加载会调用ClassLoader中的本地方法defineClass()方法加载目标Java类
为了保护系统不因为引入全限定类名相同的类就导致项目使用的类被篡改导致系统崩溃,引入双亲委派机制;
自定义类与核心包存在类全限定类名相同虽然是错误写法但是不会报错
比如如果系统注入了一个与Java核心包全限定类名相同的类,此时由于双亲委派机制会由引导类加载器去加载核心包路径下JDK提供的那个类而不会去加载全限定类名相同的用户自定义类,核心包中的类全都没有main方法,如果用户自定义类的全限定类名和核心包的类相同还有main方法,会直接报错该类中没有main方法,因为只会去加载核心包的同名类
注意如果自定义的类的全限定类名在核心包没有,但是包名和核心包一样也会报错,因为Java不允许用户自定义类使用核心包名,这是为了避免用户通过让自定义类和核心包的包名相同使用引导类加载器来加载自定义类,避免用户对引导类加载器进行攻击
注意核心包下一些类只有接口,自实现由第三方提供,这些实现类使用当前线程上下文Thread.currentThread().getContextClassLoader()获取类加载器执行类加载,默认是应用类加载器
双亲委派机制优势和弊端
优势
类加载器有层次关系导致类也有层次关系,双亲委派机制可以,确保一个类只能被唯一确定的类加载器加载,而且加载前还会去检查类加载器的命名空间中是否因为类已经被加载过有对应的class对象,确保类的全局唯一性避免类被重复加载
保护系统安全,率先使用引导类加载器加载核心包防止用户通过命令相同全类名的类来篡改核心API,不允许使用核心包包名防止引导类加载器被用户随意使用[使用核心API的包名会导致JVM启动报错并抛出SecurityException],这种对java核心源代码的保护被称为沙箱安全机制
弊端
双亲委派模型会导致顶层的类加载器无法访问底层类加载器所加载的类,这会导致系统核心类无法访问用户自定义的应用类,比如JDK在系统类中提供一个接口,该接口在应用类中得到实现,该接口还绑定了一个工厂方法用于创建接口的实例,但是因为接口和工厂方法都在启动类加载器中,此时会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题
JVM只是建议采用双亲委派模型,并没有明确要求,Tomcat的类加载器使用的加载机制和传统双亲委派模型有一定区别,当缺省的类加载器接收到一个类加载器任务首先会自行进行加载,加载失败了才会将类的加载任务委派给其父类加载器去执行,这也是Servlet规范推荐的做法
双亲委派机制的破坏方式
🔎:实际开发中有很多场景都需要上层类加载器加载的类需要使用到下层类加载器加载的类的实现,需要避开双亲委派模型单向委托的行为,有三种典型破坏双亲委派机制的行为
1️⃣JDK1.2以前不满足双亲委派模型,但是类加载器包括抽象类ClassLoader在JDK1.0就有了,用户本来就是直接覆盖loadClass()方法自定义类加载逻辑,因此只有重写loadClass方法就能破坏调双亲委派机制,而且那时重写类加载逻辑就要重写loadClass方法;
对于该问题,JDK1.2在ClassLoader中添加了一个protected的findClass()方法,引导用户将类加载器逻辑编写到该方法中,而不是直接在loadClass()方法中直接编写代码,将双亲委派的实现编写在loadClass中,在loadClass方法中当父类加载器加载失败,子类加载器再调用findClass()方法来尝试加载,实现既按照用户自己定义的方式去加载字节码二进制信息流,也能保证自定义类加载器是满足双亲委派机制的
2️⃣线程上下文类加载器
双亲委派机制存在缺陷,JDK的核心API总是由顶层的类加载器加载,但是如果核心API想要回调用户类中的代码就无法实现了[核心API需要引导类加载器加载,但是接口的实现需要由系统类加载器进行加载,引导类加载器不认识、无法加载和调用这些应用类],比如Java的标准服务JNDI的代码有引导类加载器加载,但是JNDI的设计目的就是对资源进行查找和集中管理,需要调用其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口[SPI:Service Provider Interface,Java把核心类rt.jar中提供外部服务可由应用层自行实现的接口称为SPI],Java为了解决这个问题引入了不太优雅的线程上下文类加载器[Thread Context ClassLoader]设计[默认情况下线程上下文类加载器就是系统类加载器],
当父类加载器想用子类加载器加载的类时委托线程上下文类加载器去调用子类加载器加载的类,这个过程的目的是父类加载器以线程上下文类加载器为中介去请求子类加载器完成类加载的行为从而实现在引导类加载器加载会调用用户实现类中方法的SPI核心类时由引导类加载器去请求子类加载器尝试加载需要调用的用户类,这个过程违背了双亲委派模型的原则,Java中的SPI比如JNDI、JDBC、JCE、JAXB、JBI都是使用这种方式来实现的,JDK6为了消除这种极不优雅的实现方式,提供了java.util.ServiceLoader类以META-INF/services中的配置信息和责任链模式才给SPI的加载提供了相对合理的解决方案
3️⃣这种方式是由于用户对代码热替换、模块热部署等对程序动态性的追求导致的,所谓的热部署、热替换就像电脑一样不需要停机就能替换鼠标键盘等外设
2008年IBM主导的JSR-291的项目即OSGI R4.2针对模块化部署的标准把Oracle和SUN公司按在地上摩擦,Oracle公司不服气在Java9引入了jigsaw模块化新特性才渐渐实现了Java模块化事实上的标准,此时OSGI已经比较成熟了,这里主要介绍OSGI实现的热部署
OSGI实现模块化热部署的关键是其实现的自定义类加载器机制,让每一个Bundle程序模块都有一个字节的类加载器,这些自定义的类加载器不再满足双亲委派模型推荐的树状结构,采用更进一步发展的更加复杂的网状结构,更换Bundle即更换程序模块时把Bundle连同类加载器一起换掉实现代码的热替换
OSGI只针对部分类使用双亲委派模型,其余的类都是在平级的类加载器中进行,OSGI的类加载器设计不符合双亲委派模型原则,而且为了实现热部署带来了很高的复杂度,但是业界技术人员都认为弄懂了OSGI对类加载器的运用才算是掌握了类加载器的精髓
热替换:在不停止服务的前提下通过替换程序文件来修改程序的行为,大部分脚本语言比如PHP天生就支持热替换,只要替换了PHP源文件,无需重启WEB服务器改动就会立即生效;Java中最初不支持热替换,因为修改了类文件无法让系统自动重新加载被修改的类,只能通过自定义类加载器来实现这个功能,此外不同的类加载器加载同一个字节码文件在JVM内部也会认为这是两个完全不同的类。Java可以自定义类加载器,通过自定义类加载器在一定条件下触发对修改后的字节码文件的加载并替换掉旧的Class实例从而实现不停机的情况下程序的热替换,这种多个自定义类加载器实例加载同一个字节码文件生成不同class实例的方式也破坏了双亲委派模式
沙箱安全机制
沙箱安全机制的作用是保证原生的程序运行环境的安全,沙箱就是限制程序运行的一个环境,沙箱机制是将Java程序限定在JVM特定的运行范围中,严格限制代码对本地系统资源如CPU、内存、文件系统、网络的访问,保证代码的有限隔离,防止对本地系统造成破坏,不同级别的沙箱对资源的访问限制不同,所有的Java程序的运行都可以指定沙箱并定制安全策略
JVM沙箱安全机制的变更
JDK1.0本地代码可以访问一切本地资源,远程代码被认为是不受信任的被限制在沙箱环境中严格限制对本地系统资源的访问
缺陷是影响程序的功能扩展,用户无法实现通过远程代码访问本地系统文件,
JDK1.1增加了安全策略,允许用户指定远程代码对本地系统资源的访问权限,受信任的远程资源可以访问本地系统资源
JDK1.2增加了代码签名,设置了多个不同的权限组,不管是本地代码还是远程代码都可以统一分配到不同的权限组中分类管理不同代码对本地系统资源的访问权限,不同权限组的权限不同
JDK1.6的沙箱安全机制引入了域的概念,域可以分为系统域和应用域,系统域专门负责与系统资源的交互,应用域通过系统域代理对系统资源的访问,JVM中不同的应用域都对应不同的权限,代码会被加载到不同的系统域和应用域中,不同域中的类文件具备当前域的全部权限
垃圾回收称GG[Garbage Collection]、垃圾回收器也称GC[Garbage Collector]
垃圾收集方法和垃圾收集算法有哪些以及各自的特点
虚拟机栈不需要垃圾回收,调用方法过程中执行方法入栈,方法执行完毕出栈即可;可以认为95%的垃圾回收集中在堆区,5%集中在方法区[JDK8以后使用元空间本地内存替代方法区,内存大,一般不会出现内存溢出问题]
串行和并行:用户线程和垃圾回收线程不能同时执行,串行即垃圾回收线程只有一条,并行指垃圾回收线程有多条
并发指垃圾回收线程和用户线程可以同时执行[即不会出现所有用户线程出现stop-the-world的情况]
垃圾回收的意义:避免内存被耗尽,清除内存碎片,整理出新的大段内存分配给新的对象,除了Java,C#,Python、Ruby都使用自动内存分配和自动垃圾回收的方式
如果不对内存中的垃圾进行处理,这些垃圾对象会一直保持到应用程序结束,最终可能导致内存溢出
内存泄漏:对象本身不再使用,但是试图回收的时候发现对象还存在指向的引用比如C/C++的忘记释放内存导致没有办法被回收,引用计数算法中没有特意处理循环引用的问题导致内存泄漏,Java中定义的内存泄漏就是用户已经不会再使用某个对象,但是垃圾回收时还是发现可达性分析时对象还是可达的
自动的内存分配和垃圾回收会弱化开发人员的内存溢出时的定位和解决问题的能力,需要使用监控工具对自动化技术实施必要的监控和调节
垃圾回收算法
垃圾标记阶段
引用计数算法
概念:每个对象都有一个整形引用计数器属性,记录对象被引用的次数,每有一个对象引用了该对象,引用计数器就加1,每当一个引用失效,引用计数器就减1,引用计数器的值为0,表示对象不可能再被使用,可以进行垃圾回收,只要发现引用计数器为0,不需要等待内存区域空间不足就会直接回收
优点:实现简单,垃圾对象便于标识,相较于可达性分析算法效率高,回收没有延迟性
缺点:
引用计数器属性增加了内存空间上的开销,每次新增或者销毁对象引用都会对引用计数器进行更新,增加了运行性能开销
引用计数算法最致命的问题是没法处理循环引用的问题,导致Java的垃圾回收器没有使用该算法
循环引用是指比如一个引用指向一个环形链表或者两个对象互相指向彼此,销毁引用时,环形链表中的所有对象都被环形链表中的其他对象引用,因此环形链表并不会被销毁,导致整个环形链表内存泄漏
通过两个对象循环引用也能证明Java没有使用引用计数算法
Python使用了引用计数算法,Python解决循环引用的问题主要通过手动解除和弱引用两种方式;手动解除就是显式将循环引用的指针销毁;弱引用是Python提供的标准库,循环引用使用弱引用,只要发现对象引用是弱引用就对对象进行回收
可达性分析算法[根搜索算法、追踪性垃圾收集]
可达性分析算法最重要的就是解决在引用计数算法中出现的循环引用问题,被Java、C#使用
概念:内存中的存活对象都会被根对象集合中的根对象直接或者间接的连接,以根对象集合GC Roots作为起始点,在集合中从上到下从根对象开始搜索,检查根对象引用了哪些对象实体,这些对象实体又引用了哪些对象实体,整个从根对象开始的树上的对象都被认为是存活对象,存活对象被认为是可达的;垃圾对象无法通过根对象访问到,即没有和引用链相连的对象认为是不可达的,被认为对象已经死亡,可以被标记为垃圾对象
根对象集合是一组必须活跃的引用,GC Roots中包含以下元素,判断技巧是如果一个指针指向堆内存中的对象但是又不存放在堆内存中,指针指向的对象就是一个根对象
虚拟机栈中局部变量表中的引用数据类型引用
本地方法栈中引用数据类型引用
类静态属性引用
串池中引用
同步监视器[同步对象]即同步锁持有的对象
JVM内部的引用比如基本数据类型对应的class对象,常驻的像空指针、内存溢出等异常对象,系统类加载器
反映虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
GCRoots中还有可能加入一些临时的对象,比如只对堆的新生代进行局部垃圾回收,非新生代比如老年代或者其他关联区域的对象也会临时加入到GC Roots中去考虑来保证可达性分析的准确性
从根对象开始搜索走过的路径被称为引用链
可达性分析需要在一个保证一致性的快照中进行,不能一边分析引用关系根节点集合还在不断地变化,因为这点导致GC时必须STW停下所有用户线程,即使是号称不会停顿的CMS低延迟并发垃圾收集器枚举根节点也必须要停顿下来
对象的finalization对象终止机制
目的是允许开发人员在object.finalize()方法中自定义对象销毁之前的额处理逻辑,一般是用于对象在被回收前对计算机资源进行释放,比如关闭文件、关闭套接字和数据库连接等
不要试图去主动调用一个对象的finalize()方法,应该交给垃圾回收器进行调用,原因主要有以下三点
主动调用finalize()方法可能导致对象复活
主动调用finalize()方法也是由fianlizer线程执行,该线程优先级比较低,即使主动调用也不一定会立即被执行,极端情况下不发生GC,finalize()方法没有执行的机会
重写fianlize()方法如果性能太差,或者陷入死循环,会严重影响GC的性能
因为finalize()方法执行时机的存在,JVM中的对象一般有三种可能的状态;一个不可达的对象也不是必须被销毁,在某个条件下不可达对象可能被复活
可触及状态:对象可达
可复活状态:对象所有的引用都被释放,对象不可达,但是对象可能在finalize()方法中被复活
不可触及状态:对象不可达,且finalize()方法被调用后没有复活对象就会进入不可触及状态,此后对象不可能被复活,只要用户不主动调用对象的finalize()方法,该方法在对象生命周期中只会被调用一次
机制流程
如果对象obj到GC Roots没有引用链,进行第一次标记
被标记的对象判断是否有必要执行finalize()方法
obj没有重写finalize()方法,或者fianlize()方法已经被虚拟机调用过了,则finalize()方法不会被执行,obj会被判定为不可触及
如果obj重写了finalize()方法且没有被执行过,obj会被插入到F-Queue队列中,由JVM创建的一个低优先级的fianlizer线程触发fianlize()方法的执行
finalize()方法执行完GC会对obj进行第二次标记,如果obj在finalize()方法中变得可达,第二次标记时,obj会被移出即将被回收对象的集合;此后一旦obj再次不可达,此时被第一次标记后不会再调用finalize()方法,对象直接变成不可触及状态,然后再被第二次标记直接被销毁
MAT的GC Roots溯源
显示GC Roots方面,Jvisualvm没有MAT这么方便,找到GC Roots通过引用链来判断本该被回收的对象哪个位置存在内存泄漏
MAT[Memory Analyzer]:是基于Eclipse开发的免费性能分析工具,用于查找内存泄漏以及查看堆内存的消耗情况,和jconcolse、jProfiler、jvisualvm等功能类似
MAT可以离线分析通过命令行jmap -dump:format-b,live,file=test1.bin 进程号命令生成或者jvisualVM的监视选项卡下的堆Dump导出的离线dump快照文件[Jvisualvm关闭dump文件就没了,需要另存为才能保存]
通过JVM参数-XX:+HeapDumpOnOutOfMemoryError可以在系统发生OOM时会在当前模块根目录下自动生成以.hprof结尾的dump文件
流程
生成离线Dump文件
MAT-File-open File打开dump文件,蓝色图标下拉列表--Java Basics--GC Roots就能看到根对象集合中的所有对象
Eclipse对GC Roots中的对象采用了自定义的分类标准,通过线程可以查找当前线程下的所有根对象
JProfiler的GC Roots溯源
流程
JProfiler可以在Heap Walker中动态显示每个类的对象实例数量,针对某个类点击Show Selection In Heap Walker可以单独展示一个类下的所有对象实例
点击Refernces选项卡,选择Incoming refernces点击Show Paths To GC Root可以展示当前对象对应的根对象
使用JProfiler可以直接打开dump文件
通过Biggest Objects可以查看每个对象的大小,可以直接定位到因为对象太大导致的内存溢出的对象
通过Heap Walker的Thread Dump可以查看哪个线程出现了OOM异常
垃圾清除阶段:执行死亡对象的垃圾回收,释放掉无用对象占用的内存空间
标记清除算法[Mark-Sweep]
1960年提出并被应用到Lisp语言中,堆中有效内存空间被耗尽,STW停止所有用户线程,标记所有垃圾,然后对垃圾进行清除
标记:GC从引用根对象开始遍历,标记所有的可达非垃圾对象,标记会记录在对象的对象头中
清除:GC对堆中所有对象进行线性遍历,将没有被标记的非可达对象进行回收,所谓的清除也只是将需要清除的对象地址保存在空闲地址列表中,为新对象分配内存时判断某块垃圾位置大小是否足够,够就直接覆盖掉垃圾数据,回收实际上回收的是地址,垃圾数据在写入新数据的时候才会清除
优点:容易理解
缺点:
标记遍历所有可达对象时间复杂度O(N),清除阶段遍历所有对象时间复杂度O(N),效率相较于其他两种算法不高
GC时会停止整个应用程序,用户体验差
清理出来的空闲内存不连续,会产生内存碎片,导致内存不规整为对象分配内存需要维护一个空闲列表
复制算法
1963发布论文并由论文发布者本人引入到Lisp语言的一个实现版本中
原理:准备两块空间可用的内存空间,一次只使用其中一块,垃圾回收时标记阶段将存活的对象规整复制到未被使用的内存块,交换两个内存块的角色完成垃圾收集,新生代的幸存者区就使用的复制算法
优点:
没有标记和清除过程,实现简单,运行高效
垃圾收集后的内存空间规整,不会出现碎片问题
缺点:
需要两倍的内存空间
对象移动后引用地址会发生变化,垃圾回收还需要重新改变引用对应对象的地址值,内存和修改引用上的开销也不小
对于只有少量存活对象的场景表现还行,但是如果极端情况下,所有的对象都存活,此时复制算法相当于把所有对象原封不动重新拷贝到另一块内存空间,而且还要重新改变所有对象的引用地址值,无效开销非常严重;因此复制算法只适用于内存充足且每次垃圾回收存活对象非常少的情况,比如新生代这种死亡率达到70%-99%的内存空间使用复制算法比较合适,也靠频繁的GC来降低双倍内存的开销;像老年代这种对象存活时间久就不适合使用复制算法
标记压缩算法[Mark-Compact、标记整理算法]
标记清除算法能用在老年代,但是对象过大会直接进入老年代,标记清除算法内存碎片太多,如果要使用标记清除算法,本身效率就不高,还要专门对内存进行规整处理
标记压缩算法是在标记清除算法的基础上进行的优化,1970年发布了标记压缩算法,很多现代的垃圾收集器中都使用的标记压缩算法及其改进版本
原理:
标记:从根节点开始标记所有被引用的对象
压缩:将所有存活的对象整理到内存的一端按顺序排放
优点:
内存规整,使用指针碰撞来为对象分配内存无需使用空闲列表来记录空闲内存的地址,消除了复制算法内存减半的高额代价
缺点:
效率低,甚至比标记清除算法还低一些,比复制算法多了一个标记环节,比标记清除算法多了一个垃圾整理环节
需要更改移动后的对象的引用地址
移动过程也需要STW,暂停用户线程的时长也会长一些
分代搜集算法
分代搜集算法不是一个真正的算法,只是基于不同对象生命周期不同策略性地选择不同的搜集方式提高垃圾的回收效率[跟业务挂钩的对象生命周期就会长一些,程序运行期间产生的临时变量生命周期就短一些,不可变类的特性每次更新操作都会创建对象决定了这些类的生命周期比较短]
目前几乎所有的垃圾回收器都是采用分代收集算法实现的,堆区一般分成新生代和老年代两个区域,几乎所有的垃圾回收器都区分新生代和老年代
新生代区域小,对象生命周期短,存活率低,回收频繁;回收频繁要求算法效率高、存活率低对应存活对象少,正好适合使用复制算法;复制算法的弊端内存开销大,通过细分新生代,引入幸存者区屏蔽掉生命周期极短的对象缓解内存浪费;新生代占堆区的1/3,单个幸存者区占新生代的1/10,只有1/30的内存额外开销
老年代区域大,对象生命周期长,存活率高,回收频率低;一般使用标记清除算法和标记整理算法混合实现,CMS是针对老年代的垃圾回收器,基于标记清除算法实现,对于内存碎片问题的解决是当由于内存碎片导致对象分配问题时,补偿使用基于标记压缩算法的Serial Old垃圾回收器执行Full GC整理老年代的内存
增量收集算法
分代收集算法STW的时间比较长,严重影响用户体验和系统稳定性,增量收集算法为了解决STW时间长的问题
原理:
每次垃圾收集线程只收集一小块内存空间,接着继续执行用户线程,依次反复直到垃圾收集完成,即把集中在一块的STW时间分散到一段时间区间内,增量收集算法协调垃圾收集线程和用户线程的执行
缺点:
频繁在垃圾回收线程和用户线程间进行上下文切换,会增加垃圾回收的整体成本,造成垃圾回收器吞吐量的下降
分区算法
分区算法将整个堆空间划分成不同的小区间[region],每个小区间独立使用,独立垃圾回收,有的小区间作为伊甸园区、有的小区间作为幸存者区,有的小区间作为老年代,有的小区间放大对象;根据GC的指定时间区间可以控制单次垃圾回收回收多少个小区间,降低GC导致的停顿间隔
实际的GC实现过程要复杂的多,前沿GC都是复合算法,并且并行和并发兼备[并行指多个垃圾回收线程、并发指垃圾回收线程和用户线程同时运行]
三种垃圾清除算法的对比[没有最好的算法,都有优缺点,需要根据应用场景选择使用]
速度:复制算法最快、标记清除算法中等、标记压缩算法最慢
内存开销:复制算法需要活对象两倍的开销、标记清除算法和标记压缩算法都是正常开销,标记清除算法有内存碎片,标记压缩算法没有内存碎片
移动对象:复制算法和标记压缩算法都会移动对象,标记清除算法不会移动对象
GC对象的判定方法有哪些,即如何判断对象已死亡
垃圾:程序运行期间没有任何指针指向的对象,不够准确,在可达性分析中不可达的对象,即无法通过GC Roots根对象集合中的对象访问到的对象
可达性分析、finalize方法、各类引用关联对象的状态和生命周期
如何判断一个常量是废弃常量
简述GC流程,对象如何晋升到老年代
简述常见垃圾回收器的优缺点,重点阐述CMS和G1[原理、流程、优缺点]
GC没有被JVM规范要求必须使用具体的垃圾回收器,很多厂商都有自己的实现,发展至今已经衍生了众多GC版本,JDK每个版本的更新都会提到GC的变化
垃圾回收器的分类
按垃圾回收线程的线程数分可以分为串行和并行垃圾回收器,串行垃圾回收器适合单CPU或者硬件不是特别优越的场合,串行回收器的性能表现比并行或者并发回收器表现更好,串行回收器默认工作在在客户端模式下的JVM中
按照工作模式可以分为并发式垃圾回收器和独占式垃圾回收器,独占式指垃圾回收线程工作时其他用户线程全部暂停;并发式指垃圾回收线程和用户线程交替工作,减少用户线程的单次停顿时间
并发垃圾回收器一次垃圾回收期间部分多段时间只能垃圾回收线程工作,部分时间垃圾回收线程可以和用户线程同时工作
按照碎片的处理方式可以分成压缩式垃圾回收器和非压缩式垃圾回收器,压缩式指垃圾回收完以后是否对存活对象进行规整整理,压缩式垃圾回收使用指针碰撞的方式再分配对象空间,非压缩式垃圾回收使用空闲列表的方式再分配对象空间
按照处理的内存区域可以分为年轻代垃圾回收器和老年代垃圾回收器
新生代垃圾回收器只能回收新生代:Serial、Parallel Scavenge、ParNew
老年代垃圾回收器只能回收老年代:Serial Old、Parallel Old、CMS
整堆收集既可以回收新生代也可以回收老年代:G1
JDK8及以前可选择的组合关系:Serial+CMS/Serial Old;ParNew+CMS/Serial Old;Parallel Scavenge+Serial Old/Parallel Old
因为CMS是并发垃圾回收器,垃圾回收的同时用户线程还会继续执行,因此CMS不能等到老年代空间满了再进行回收,如果确实回收晚了或者垃圾制造速度比回收速度快导致CMS垃圾回收失败,此时会选择Serial Old作为后备方案来容错,因此使用CMS需要使用Serial Old作为兜底垃圾回收器
JDK8中Serial+CMS和ParNew+Serial Old的组合过时但是还可用,JDK9中这两个组合完全被禁止使用
JDK14中Parallel Scavenge+Serial Old过时但是还可以使用,CMS被移除,这是因为JDK9中G1被引入替代了CMS
JDK8中的默认组合是Parallel Scavenge+Parallel Old
Parallel Scavenge底层使用的框架和CMS不同,导致两个不能组合使用
存在多种不同组合的原因是Java在移动端和服务器端都有很多应用场景、针对不同场景的要求对垃圾回收器的要求不同,没有万能的垃圾回收器,只能选择针对具体应用最合适的垃圾回收器

GC的性能指标:吞吐量、暂停时间和内存占用三者是相互矛盾的关系,暂停时间的重要性越来越重要,随着硬件技术的发展,内存占用可以越来越大,硬件性能的提升也促进吞吐量的提升,但是内存的扩大也导致STW的时间更长;现在GC优化的重点就是降低STW的时间实现低延迟;针对不同场景具体要求也不同,像Parallel并发垃圾回收器更关注吞吐量、CMS、G1、ZGC更多地关注暂停时间;设计或使用GC算法时必须确定GC是更专注高吞吐量还是低延迟,在二者之间找到一个合适的折中点;像G1的标准是在优先降低暂停时间控制暂停时间的最大值的基础上再考虑提升吞吐量
吞吐量:总运行时间等于程序运行时间加内存回收时间,吞吐量为程序运行时间占总运行时间的比例
吞吐量越高,程序对暂停时间的要求就越低,通过减少GC线程活跃的频率来提升吞吐量会导致单次暂停时间的增加导致高延迟
服务器更看重吞吐量
垃圾收集开销:内存回收时间占总运行时间的比例
暂停时间:单次STW的时间,即执行垃圾收集时用户线程被暂停的时间
暂停时间通过降低每次GC的内存大小,增大GC的频率来实现,总的吞吐量会降低,总的STW时间因为线程上下文切换会增加,因此低暂停时间会拉低吞吐量
200ms的暂停时间都可能打断终端用户的体验,交互式的客户端应用程序更多关注低暂停时间即低延迟
收集频率:垃圾收集的频率
内存占用:堆区大小
快速:对象从诞生到被回收经历的时间
垃圾回收器的发展历史
1999年随JDK1.3发布的串行垃圾回收器Serial,是第一款垃圾回收器,在单核CPU和客户端场景下的性能表现不错;ParNew是Serial的多线程即并行版本,在服务端性能更好一些
2002年随JDK1.4发布Parallel GC和Concurrent Mark Sweep GC[CMS],Parallel在JDK6成为HotSpot的默认GC,适用于新生代的GC,老年代使用Parallel Old GC;关注低延迟的场景下更多地选择CMS;Parallel和Parallel Old搭配使用,但是不能和CMS搭配使用,CMS可以和ParNew搭配使用;在不同生产环境下,可以使用parallel和parallel Old组合或者ParNew和CMS组合;在硬件性能比较低的场合下才会考虑Serial和Serial Old的组合
2012年随JDK1.7发布了G1,2017年JDK9中G1取代CMS成为HotSpot的默认垃圾回收器,CMS被提示过时且后续将会废弃,2018年G1通过并行垃圾回收来改善垃圾回收器的延迟,实现在限定暂停时间的前提下尽可能提高吞吐量
G1的特点是兼具回收新生代和老年代,G1使用的是分区算法
2018年随着JDK11发布引入Epsilon垃圾回收器和实验性的可伸缩的低延迟垃圾回收器ZGC,也称No-op无操作垃圾回收器
2019年JDK12发布在Open JDK中引入了红帽公司研发的Shenandoah GC,该垃圾回收器也是专注于低延迟,同时对G1和ZGC做自动返回未使用对内存给操作系统
2020年随着JDK14的发布删除了CMS垃圾回收器,即使配置了CMS也会使用默认的G1,但是JVM不会报错只会警告,此前ZGC只能使用在Linux系统上,2020年扩展了ZGC可以在mac和windows上使用
七款经典垃圾回收器[所谓经典就是已经被商用检验的]
串行回收器:Serial、Serial Old
并行回收器:ParNew、Parallel Scavenge、Parallel Old
并发回收器:CMS、G1
新时期还在发展中处于实验阶段的垃圾回收器:Shenandoah、ZGC、Epsilon
选择垃圾收集器的策略
客户端或者嵌入式这种内存和CPU资源贫瘠选择Serial
吞吐量最大化选择Parallel
低延迟暂停时间最小化选择CMS
垃圾收集器的选择指标[国内只有像阿里这种需要需要优化底层垃圾收集器的底层算法、一般的程序员只需要关注根据应用场景选择合适的垃圾收集器]
优先调整堆大小让JVM自动调整,不一定特别好但是一定不会差
内存比较小,使用串行GC
单核单机程序,并且没有暂停时间要求使用串行GC
多核、要求高吞吐量、允许暂停时间超过1s,可以选择parallel并行垃圾收集器
多核、低暂停时间、互联网应用这类需要快速响应的场景,选择G1这种并发GC,现在的互联网项目基本都使用G1
小结
新生代都是复制算法、老年代除了CMS是标记清除算法其他都是标记压缩算法
在JDK14及以后能使用的组合只有Serial+Serial Old;Parallel+Parallel Old;G1
GC发展阶段:
Serial
Serial曾经作为HotSpot的Client模式下的默认新生代垃圾回收器,基于复制算法、串行回收和STW机制的串行垃圾回收器
Serial还提供用于老年代垃圾收集的Serial Old垃圾收集器,基于标记压缩算法、单垃圾回收线程串行回收和STW,Serial曾经作为HotSpot的Client模式下默认的老年代垃圾回收器,Serial Old在Server模式下主要与Parallel Scavenge组合作为老年代垃圾回收器以及作为CMS的兜底垃圾收集方案
多核CPU场景下依然可以选择Serial,但是效率不高,一般不会这么设置;只有单核CPU场景下才会用,现在客户端设备单核CPU也很少了,基本上只有嵌入式设备才会考虑
HotSpot虚拟机可以使用JVM参数-XX:+UseSerialGC指定年轻代和老年代都使用Serial垃圾收集器,注意没有JVM参数-XX:+UseSerialOldGC,使用启动会报错
Serial发布古老,设计简单,但是Serial不管是垃圾回收相关数据结构还是垃圾回收线程上的开销都非常小,随着云计算的兴起,在Serverless等新的应用场景下被广泛使用
优点:
单CPU场景下节省了线程上下文切换的开销,单CPU场景下效率相较于其他垃圾回收器更高,适合运行在JVM的Client模式下,适用于交互性较强的场景下
缺点:
因为该垃圾收集器时串行的,暂停时间太长,JavaWeb应用程序完全不会考虑串行垃圾收集器
ParNew
ParNew是Serial的多线程版本,底层共享了Serial的很多代码,同样在新生代中采用复制算法,STW机制,因为多条垃圾回收线程进行垃圾收集,STW的时间会短一些
ParNew曾经作为很多JVM在Server模式下的新生代默认垃圾收集器,后续随着和Serial Old组合在JDK9的过时以及JDK14中CMS的移除几乎落幕,JDK9开始配置在JVM中使用ParNew就会警告不建议使用,在将来会移除
新生代回收频繁,使用并行方式更高效
CPU多核场景下ParNew效率更高,单核场景下没有Serial高效,因为线程上下文切换还会有额外的开销
使用JVM参数-XX:+UseParNewGC指定使用ParNew垃圾收集器,该参数只会指定新生代使用ParNew,不会影响老年代;通过JVM参数-XX:ParallelGCThreads可以设置垃圾回收线程的数量,默认垃圾回收线程的数量是CPU的核心数,建议不要超过CPU核心数,避免多个线程竞争同一个核
Parallel
Parallel和ParNew都一样采用复制算法、并行回收和STW机制,性能上差别也不大;主要区别是parallel的侧重吞吐量,在吞吐量达到一定值以后尽可能提升暂停时间;而ParNew的是在满足指定暂停时间的前提下尽可能提升吞吐量,并且parallel和parallel Old采用的底层框架也不同,导致Parallel和CMS不能组合使用
此外parallel相较于ParNew多了一个自适应调节策略,能够动态调整堆内存分配情况来配合实现高吞吐量
高吞吐量适合不需要太多交互任务的后台运算场景,适合执行批量任务、订单处理、工资支付、科学计算等服务器应用程序
Parallel在JDK1.6提供了Parallel Old处理老年代的垃圾收集代替原来适合单核场景下的Serial Old[parallel因为架构问题不能和CMS组合使用],因为ParNew和CMS搭配比Parallel和Serial Old搭配更好,补上了parallel Old就可以名正言顺的用Parallel,因此Parallel和Parallel Old也是JDK8中Server模式下默认的垃圾回收器
parallel Old使用标记压缩算法,基于并行回收和STW机制
相关JVM参数
-XX:+UseParallelGC和-XX:+UseParallelOldGC分别是手动指定新生代使用Parallel和老年代使用Parallel Old,只要其中一个被开启,另一个也会自动开启;
-XX:ParallelGCThreads设置新生代Parallel的线程数,默认情况下,CPU核数小于8,ParallelGCThreads等于CPU核数;CPU核数大于8,ParallelGCThreads等于(5*CPU核数)/8向下取整后加3,比如12个核心对应10个垃圾回收线程
-XX:MaxGCPauseMillis:设置最大暂停时间,为了将暂停时间控制在该指定值内,parallel会自动调整堆大小和其他参数,因为parallel因为注重吞吐量适合在服务端,因此建议谨慎配置该参数,因为该时间设置的比较小,JVM就会自动调小堆内存大小,导致GC频率增高;频率高了多了线程上下文切换的开销以及每次额外工作的开销导致整体吞吐量会下降
-XX:GCTimeRatio:设置垃圾收集时间占总时间的比例即设置垃圾收集开销1/(N+1),设置的是1/(N+1)中的N,默认值为99,即垃圾回收的时间不超过程序运行时间的1%,暂停时间越短越容易超过设定的该参数
-XX:+UseAdaptiveSizePolicy:设置parallel启用自适应调节策略,默认情况下该参数就是开启状态,会自动调整年轻代的大小、伊甸园区和幸存者区的比例,晋升老年代对象年龄阈值等参数,期望能尽量满足预设的吞吐量和停顿时间;使用parallel通常会手动指定堆空间的大小
CMS[Concurrent-Mark-Sweep]
CMS就如同其名字一样是基于标记清除算法实现的HotSpot中第一款真正意义上的并发垃圾收集器,第一次实现了垃圾回收线程和用户线程同时工作;CMS也会有STW
CMS的关注点是保证暂停时间的前提下尽可能提升吞吐量,非常适合互联网站或者B/S系统的服务端这类特别注重请求的响应速度的场景
因为CMS和Parallel底层框架不兼容,使用CMS回收老年代时,新生代只能选择ParNew或者Serial中的一个;在JDK9兼具并发并行特点的G1发布前,CMS的使用非常广泛,至今仍然有很多系统在使用CMS
工作原理:
1️⃣:单个垃圾回收线程进行初始标记,此时会STW,持续时间非常短
初始标记的任务是标记出GC Roots直接关联的根节点对象,因为只是对根节点对象进行标记,因此这个环节执行速度非常快
2️⃣:单个垃圾回收线程执行并发标记,此时不会STW
并发标记的任务是从根节点对象开始遍历所有存活对象,该过程耗时长但是不需要暂停用户线程
3️⃣:多个垃圾回收线程执行重新标记,此时会STW
因为用户线程在并发标记过程仍然会运行,因此并发标记完成后GC Roots和原本部分存活的对象可能产生变化,JVM采用的是三色标记法,完全没有被GC访问过的对象会被标记为白色,被GC访问过但该对象直接引用到的其他对象没有全部访问成功被标记为灰色,对象以及其直接引用的其他对象都被GC访问到被标记为黑色,在初始标记和并发标记期间一些对象的直接对象因为还没来得及被创建被标记为灰色,被标记为黑色和灰色的对象都不会被回收,在并发标记期间因为引用关系变化还可能涉及到多标和漏标,
多标是对象已经被标黑或者标灰,但是在并发标记期间引用被断开变成不可达对象,这就是浮动垃圾,此外并发标记开始后创建的新对象会被直接标记为黑色,如果这部分对象在并发标记期间变成了垃圾也会直接变成浮动垃圾,浮动垃圾本轮GC不会被清除,需要等到下一轮垃圾回收才会被清除,浮动垃圾也不会影响到垃圾回收的正确性;
漏标是并发标记过程灰色对象直接或间接断开白色对象的引用,黑色对象重新直接或间接引用被断开的白色对象,同时满足这两个条件由于GC不会再去遍历已经被标黑的对象,这些白色对象在本轮GC因为已经被灰色对象断开无法再被GC访问,无法被标黑或者标灰,如果不进行处理这些对象会被当做垃圾回收,这会直接影响用户程序的正确性,是不可接受的;解决办法是并发标记期间对象引用被断开又被其他对象引用时将该对象放入特定的集合,并发标记结束后停止所有用户线程遍历重新标记集合中的对象;CMS通过写屏障加增量更新[当对象有新引用插入时记录下新的引用对象等待遍历重新标记]的方式通过黑色对象重新引用白色对象记录下对象破坏对象被漏标的第二个条件;G1通过写屏障加原始快照SATB,当灰色对象断开白色对象的引用时记录白色对象到特定集合中等待被重新标记破坏对象被漏标的第一个条件,本质还是让垃圾回收按照并发标记前的快照来执行垃圾回收,应该被清理的垃圾当做浮动垃圾处理;ZGC是通过读屏障的方式在读取对象的成员变量还没到断开引用就记录下该成员变量等待遍历重新标记破坏对象被漏标的第一个条件
重新标记的任务是对可能发生漏标的对象进行重新标记,多标的对象或者并发标记过程新产生的对象变成垃圾一律当做浮动垃圾等待下一轮GC再清理,重新标记过程会STW,但是停顿时间比初始标记阶段稍长,但是远比并发标记阶段时间短
这里有两篇三色标记算法和增量更新的博客,闲了再看看,12.垃圾收集底层算法--三色标记详解,一文读懂-JVM三色标记法与读写屏障
4️⃣:单个垃圾回收线程执行并发清除,此时不会STW
并发清除阶段的任务是清理掉标记阶段判定死亡的对象,因为不需要移动存活的对象,因此用户线程可以同时执行
5️⃣:单个垃圾回收线程执行重置线程,此时不会STW
特点:
只有并发标记和重新标记两个阶段进行了STW,暂停时间非常短,目前没有任何一款GC能做到完全不需要STW
因为垃圾回收期间用户线程还会继续执行,因此CMS开始回收时需要确保用户线程有足够的内存可以使用,此前的垃圾回收器都是内存区域几乎满了再收集,而CMS是堆内存使用率达到某一阈值就开始垃圾回收;如果CMS垃圾回收期间预留的内存无法满足用户线程的需要,会出现Concurrent Mode Failure失败,此时JVM将会临时启用Serial Old来替代CMS进行老年代的垃圾收集,此时会远远增大暂停时间
因为CMS基于标记清除算法,因此只能使用空闲列表执行对象内存分配,CMS因为垃圾回收期间需要用户线程继续执行,因此如果使用标记压缩算法,规整对象的过程就必须暂停用户线程,这就会极大地增加STW的时间
CMS因为算法设计上的缺陷,目前用户群体虽然多,但是在JDK9被标记为过时,JDK14中被移除
优点
并发收集,STW时间极短,垃圾收集导致的延迟低
缺点[为了达到低延迟在更频繁的Full GC和固有的浮动垃圾内存占用两方面做了很大的牺牲,因此后面引入了基于分区算法的G1解决CMS的问题并替换CMS]
产生内存碎片,可能导致大对象无法存入老年代不得不提前触发Full GC,导致更频繁的Full GC
CMS对CPU资源非常敏感,并发阶段垃圾回收线程会占用CPU资源,垃圾收集期间系统的性能和请求的吞吐量会降低
CMS无法处理浮动垃圾,本轮GC并发标记阶段产生的浮动垃圾无法被CMS重新标记,这些在并发标记阶段产生的所有新垃圾都会被留到下一轮GC被处理,因此堆中有一部分空间始终会被浮动垃圾占用
浮动垃圾:多标的对象就是浮动垃圾
CMS相关JVM参数
-XX:+UseConcMarkSweepGC:手动指定老年代使用CMS垃圾收集器,开启该配置将自动配置-XX:+UseParNewGC在新生代使用ParNew垃圾回收器,同时会自动启用Serial Old作为CMS的兜底垃圾收集器
-XX:CMSInitiatingOccupancyFraction:设置堆内存使用率CMS垃圾回收阈值,在JDK5及以前默认值为68,老年代的空间使用率达到68%就会执行一次CMS回收,JDK6及以后默认值为92;内存增长缓慢的情况下可以设置一个比较大的阈值,能有效降低CMS垃圾回收的触发频率;如果应用程序的内存增长非常快,应该设置一个比较低的阈值,避免频繁触发Serial串行垃圾收集器,显著降低Full GC的执行机会
-XX:+UseCMSCompactAtFullCollection:指定在执行完Full GC后对内存空间进行压缩整理
注意Serial Old、Parallel Old、G1都能执行Full GC,JVM默认使用Serial Old执行Full GC,使用其他两种垃圾回收器由对应垃圾回收器执行Full GC
注意Full GC可以选择压缩也可以选择不压缩
-XX:CMSFullGCsBeforeCompaction:设置在执行多少次不压缩的Full GC后对内存空间进行压缩规整
-XX:ParallelCMSThreads:设置CMS的垃圾收集线程数量。CMS的默认垃圾收集线程数是(ParallelGCThreads+3)/4,其中ParallelGCThreads是新生代并行垃圾收集器的垃圾收集线程数量,CPU资源紧张时,受CMS垃圾收集线程的影响,应用程序的性能在垃圾收集期间表现可能非常糟糕
G1
G1的设计思想是区域化分代式,在吞吐量方面parallel性能表现不错,在暂停时间方面CMS表现不错,随着内存和CPU的进一步发展和庞大复杂用户数越来越多的业务要求,需要更适应现代要求的更低暂停时间和更高吞吐量的垃圾收集器;G1的设计目标是延迟可控的情况下尽可能提高吞吐量,基于分区算法能同时回收新生代和老年代的并行全功能垃圾收集器;主要针对配备多核CPU和大容量内存的服务端应用机器,具备极高概率满足GC停顿时间的同时还兼具高吞吐量的特性;在JDK7移除了实现性标识,使用-XX:+UseG1GC来配置启用,在JDK9被设置为默认垃圾收集器
G1的原理
G1将堆分割为物理上不连续的很多不相关大小一致且在JVM生命周期中大小不会发生改变的Region区域,使用不同的Region表示伊甸园区、幸存者0区、幸存者1区和老年代,这些区域逻辑上也不再是连续的区域了
单个Region只能属于伊甸园区、幸存者区或者老年代中的一个角色,此外G1还增加了新的内存区域Humongous,用户在Humongous中存储超过0.5个region的大对象,一个Humongous的默认大小就是一个region的大小,如果一个H区放不下一个大对象,G1会组合连续的H去来存放大对象,如果找不到能够存放该大对象的逻辑上连续的H区[注意H区存放大对象只要求H区逻辑上连续即可,其他的垃圾回收器大对象和普通对象一样也要求物理上是连续的],此时就会直接启动Full GC,在逻辑上H区从属于老年代;大对象因为新生代放不下会直接放在老年代,但是一个短期存在的大对象随着老年代一起GC会长时间无效占用堆内存,
JVM维护了一个空闲列表记录空白的Region,空闲列表中的Region被使用时可以变换成任意一种角色
因为Region之间使用复制算法进行垃圾回收,因此Region内部自然而然使用指针碰撞的方式为对象分配内存;此外单个Region中也会分配TLAB
G1会跟踪各个Region的垃圾回收可以获得的空闲空间大小以及回收需要时间的经验值,在后台维护一个优先级列表,每次根据允许的收集时间优先去回收价值最大的Region,因为这种设计方式侧重于回收垃圾最大量的区间,因为该垃圾收集器被命名为Garbage First
特点
兼具并行与并发
兼顾新生代和老年代垃圾收集:G1仍然属于分代型垃圾收集器,会区分新生代和老年代,新生代中依然区分伊甸园区和幸存者区,但是不要求每个区域在逻辑上连续,也不坚持每个区域固定大小和固定数量;而且支持一个Region动态地扮演单独一个不同种类的分代区域;只是基于分区算法兼顾回收新生代和老年代
空间整合:Region之间通过复制算法进行垃圾回收,整体上可以看做由复制算法实现的标记压缩算法,避免了内存碎片,尤其当java堆非常大的时候,G1的优势会非常明显
可预测的停顿时间模型[软实时soft real-time]:该停顿时间模型能让使用者在明确指定长度为M毫秒的时间片段内,垃圾收集的时间不超过N毫秒,吞吐量对应(M-N)/M;G1只选择部分区域进行垃圾回收,也不需要再所有用户线程全部暂停,按照Region回收价值的大小,维护一个优先级队列,每次根据指定的暂停时间,动态地选择几个回收价值最大的Region进行垃圾回收,保证在有限的垃圾回收时间内获取尽可能高的收集效率;G1不一定能做到CMS在最好情况下的低延迟,但是最差情况要比CMS好很多;软实时指的是不要求垃圾回收时间必须小于指定时间,而是指有一定的把握在指定时间内完成
G1提供YoungGC、Mixed GC和Full GC三种垃圾回收模式,分别在不同条件下被触发
除G1外的其他垃圾收集器都使用专门的优先级更低的垃圾收集线程执行并行GC,G1可以在垃圾回收线程处理速度慢的情况下自动调用应用线程来加速后台的GC工作
G1随着JDK版本迭代不断地改进,在JDK10中将串行的Full GC改成了并行Full GC,很多场景下表现都会略优于Parallel的并行Full GC
使用场景
大内存、多处理器机器的服务端应用,普通大小的堆中表现平平;为低GC延迟的大堆应用提供垃圾回收方案
当堆大小大于6GB,暂停时间可以低于500ms,通过每次只清理一部分region的增量式清理保证每次GC的暂停时间不会过长
适合使用G1替换CMS的场景:
超过50%的Java堆空间都是活跃数据
新对象分配频率或者对象年龄提升频率很高
GC暂停时间长于0.5s-1s
缺点:
G1不能全方位碾压CMS,G1为了垃圾收集产生的相较于其他垃圾收集器额外10%-20%的内存占用,在垃圾收集带来的额外执行负载方面也比CMS高,运行经验表明G1在超过6-8G的大内存应用上具备优势,内存越大优势越大;在小于6-8G的小内存应用上CMS的性能表现大概率会比G1更好
G1相关JVM参数
-XX:+UseG1GC:在JDK8或JDK7想使用G1需要显示配置
-XX:G1HeapRegionSize:设置每个Region的大小,值可以写1-32之间的二次幂,即1、2、4、8、16、32,单位是MB;Region大小设置的一般目标是按最小Java堆划分成约2048个区域,默认值是堆内存的1/2000,但是如果计算得到Region的大小小于1M就会自动取1M
-XX:MaxGCPauseMillis:设置期望的最大GC暂停时间,默认是200ms,JVM会尽力实现,但是不能保证每次都达到;一般暂停时间几十毫秒到300ms都是正常的;如果暂停时间设置的过小比如小于50ms,会降低每次垃圾回收的Region数量,增大GC的频率提高响应速度降低吞吐量;吞吐量降低可能导致GC的速度跟不上垃圾产生的速度导致堆被占满触发Full GC
-XX:ParallelGCThread:G1并行过程会出现STW,设置并行阶段垃圾回收线程的数量,最大值为8
-XX:ConcGCThreads:设置并发标记的线程数,一般设置为ParallelGCThread的1/4
-XX:InitiatingHeapOccupancyPercent:设置触发并发GC周期的Java堆占用率阈值,超过该阈值会触发GC,默认值是45即堆总大小的45%
使用G1进行调优开发人员只需要开启G1、设置堆的最大内存、设置最大暂停时间MaxGCPauseMillis,剩下的都交给JVM自动控制
垃圾收集流程
G1的一次垃圾回收过程必须包含YGC、老年代并发标记[该环节也会同时进行YGC]、混合回收[Mixed GC涉及到新生代和老年代共同回收的流程所以叫混合]三个环节;如果有需要会继续第四个串行独占高强度的Full GC[JDK10以前FullGC是串行的,JDK10提供了并行FullGC但仍然是独占式的,且暂停时间不可控,不管是什么垃圾回收器都要尽可能避免Full GC],Full GC是针对GC评估失败后提供的一种保护机制,类似于CMS回收失败替换成Serial Old执行垃圾回收进行兜底;正常情况下都不会出现Full GC,出现FullGC一般就需要进行系统的调优来避免再次出现Full GC
1️⃣YGC:伊甸园区用尽时触发YGC,幸存者区会跟着YGC被动回收,G1在YGC阶段是一个并行独占式垃圾收集器,会暂停所有用户线程,垃圾回收线程并行对年轻代进行垃圾回收;伊甸园区仍然存活的对象存入幸存者区,幸存者区达到年龄阈值的对象移动到老年代,伊甸园区移动到幸存者区的大对象幸存者区放不下也会直接移动到老年代,YGC在整个垃圾回收周期包括Full GC期间都能随时因为伊甸园区满了自动触发
YGC时G1会停止所有用户线程的执行,G1创建回收集Collection Set,将年轻代伊甸园区和幸存者区的所有内存分段全部加入到回收集中等待被垃圾回收
伊甸园区和幸存者区的存活对象通过复制算法保存到空闲的region中,该region成为新的幸存者区,幸存者区中达到年龄阈值的对象保存到空闲列表region或者老年代中,该空闲Region成为老年代,原来的伊甸园区和幸存者区成为空闲区域、
YGC的流程细节:
1️⃣扫描根:根指的是静态变量、局部变量表中的引用数据类型引用,RSet中记录的外部引用,根作为扫描存活对象的入口
2️⃣更新RSet:处理脏卡队列[dirty card queue,老年代中的对象引用年轻代中的对象通过写屏障和记忆集记录老年代对象的引用不是直接实时地将老年代对象引用记录在记忆集中,因为更新RSet需要多个线程同步,开销比较大,而是设置一个脏卡队列,当引用赋值语句执行时将属性所属对象封装成卡入队列,等到YGC前对脏卡队列中的所有卡进行处理并更新记忆集RSet,不在引用赋值语句处更新RSet是为了用户线程的性能考虑]中的卡来更新RSet,处理后的记忆集才可以真实实时反映哪些老年代对象引用了当前region中的对象
3️⃣处理RSet:识别仍然被老年代对象引用的新生代中的对象,这些对象被认为是存活对象
4️⃣复制对象:伊甸园区存活的对象被复制到幸存者区,幸存者区中存活的对象如果年龄没有达到阈值,年龄自增;达到年龄阈值的存活对象被复制到老年代,如果幸存者区空间不够,伊甸园区的部分对象也会直接晋升老年代
5️⃣处理引用:处理软引用、弱引用、虚引用、终结器引用、JNI弱引用,最终让原来的region区域置空,数据被复制到的region成为新一轮的伊甸园区和幸存者区
2️⃣并发标记:当堆内存使用率达到45%时触发老年代并发标记过程,期间仍然会触发YGC
流程细节
1️⃣初始标记:标记GCRoots中的所有根节点对象,触发一次YGC,初始标记阶段是STW的
2️⃣根区域扫描:G1扫描幸存者区指向老年代的引用,并标记这些引用指向的老年代对象,根区域扫描阶段不会STW,但是必须在YGC开始之前完成前完成根区域扫描
3️⃣并发标记:使用可达性分析算法对整个堆进行并发标记,此过程不会STW,但是可能被YGC中断,期间如果发现一个region中的所有对象全是垃圾,该region会立即被回收,并发标记过程中会计算每个region的对象活性即区域中存活对象的比例,该对象活性用优先级队列评估回收价值最高的region
4️⃣再次标记:和CMS一样,并发标记阶段用户线程也在执行,需要对可能漏标的对象进行修正,CMS使用写屏障加增量更新的方式来破坏漏标的第二个条件白色对象被黑色对象再次引用无法被察觉的条件,G1使用更快的写屏障加初始快照算法SATB破坏漏标的第一个条件白色对象被灰色对象断开无法被察觉的条件,该阶段会STW
5️⃣独占清理:计算各region的垃圾收集价值并根据回收价值进行排序,识别可以混合回收的区域,为混合回收阶段做准备;这个阶段不会做垃圾的收集,该阶段会STW
6️⃣并发清理阶段:识别和清理全是垃圾的region,此时region中还有存活对象的不会被回收
3️⃣混合回收:并发标记完成后马上开始混合回收过程,G1规整老年代的存活对象到空闲的Region,这些Region也会被自动分配为老年代,该过程因为要尽可能满足指定的暂停时间只会挑选垃圾收集价值最高的几个老年代Region进行垃圾回收,期间仍然会触发YGC
含有存活对象且垃圾占有内存空间超过65%[可以通过JVM参数-XX:G1MixedGCLiveThresholdPercent进行配置,默认值为65%,存活对象超过35%被认为存活对象占比太高,会带来更多的不必要的复制开销且会消耗更多的时间]的老年代region默认情况下会被分8次被回收,优先回收回收价值最高的region,回收次数可以通过JVM参数-XX:G1MixedGCCountTarget设置,而且只有region才会被回收
混合回收的回收集中包含1/8的未被回收的老年代region,全部的新生代region,采用和年轻代回收一样的流程进行垃圾回收,只是多了回收已经被标记存活对象的老年代region
混合回收不一定必须要进行8次,如果JVM发现可以回收的垃圾占堆内存的比例低于阈值10%,就会停止混合回收,该阈值可以通过JVM参数-XX:G1HeapWastePercent设置,默认值是10,意思是允许整个堆内存有10%的空间被浪费,避免花费很多的时间进行GC但是回收的内存却很有限
混合回收阶段Oracle有考虑设计成和用户线程一起并发执行,但是实现起来比较复杂,选择将该特性放到ZGC中去实现
4️⃣Full GC:因为Full GC是串行独占暂停时间不可控的GC,性能非常差且暂停时间很长,G1的设计初衷就是避免Full GC
导致G1进行Full GC的原因:
YGC前老年代的可用连续空间小于前几次年轻代晋升老年代的平均大小会直接将YGC替换成Full GC,方法区空间满了时[概率小,因为方法区使用的是本地内存,空间很大]
调用System.gc()时会建议系统执行FullGC,系统会根据运行情况自行判断是否执行Full GC
混合回收完成前老年代的空闲空间已经被耗尽,此时就会使用Full GC暂停所有用户线程来进行兜底垃圾收集[比如暂停时间设置的太短,回收频率变高,但是如果垃圾回收的速度跟不上垃圾产生的速度内存最终还是会被耗尽并触发Full GC]
一般正常情况下,一个最大堆内存4G的Web服务器,每分钟响应1500个请求,每45秒新分配大约2G内存,G1约每45秒执行一次年轻代回收,每31个小时整个堆的使用率达到45%,并发标记完成后执行四到五次混合回收,仅供参考
记忆集[Remembered Set、RSet]:
G1相较于其他垃圾回收器需要额外的10%-20%的内存空间来维护一个记忆集,GC的基础是可达性分析,但是YGC只回收新生代中的对象,如果我们进回收新生代中的对象还要将所有对象都遍历一遍判断新生代中的对象是否是可达的[因为新生代的对象可能仅被老年代的对象引用],回收新生代不得不扫描老年代,这是非常高昂且多余的开销;在其他的分代收集器中也存在这样的问题,但是G1不仅分代而且分Region,而且G1主要应用在大堆,堆越大可达性分析访问的对象就越多,无用的开销也越严重,会严重降低Minor GC的效率
无论是在G1还是其他分代垃圾收集器,JVM都是通过记忆集RSet来避免全局扫描,G1给每个region都配置一个RSet,如果当前region中的对象A同时被两个不同region中的对象B和C引用,就会将两个对象的引用地址记录在RSet中;每次引用数据类型写操作时都会产生一个写屏障中断写操作,检查被引用的对象和当前对象是否在同一个region中[其他垃圾收集器是检查两个对象是否一个处于年轻代,一个属于老年代],如果不在同一个region中会将当前对象记录到被引用对象所在region对应的RSet记忆集的具体实现卡表CardTable中
进行垃圾回收时,将RSet中的引用作为GC Roots的枚举范围,就能实现不进行全局扫描也不会出现存活对象被漏标
回收老年代的时候不需要特别关心记忆集的问题,因为回收老年代本身就要回收年轻代,回收老年代就相当于全堆扫描
注意事项:
要避免使用-Xmn或者-XX:NewRatio等JVM参数显式设置年轻代的大小,固定年轻代的大小会导致期待的最大GC暂停时间参数-XX:MaxGCPauseMillis失效,交给JVM自己控制就好
不要将暂停时间设置的太短,因为更短的暂停时间会导致更频繁的垃圾回收带来更多的额外如线程上下文切换的开销,导致吞吐量下降,G1设计的吞吐量目标是90%的用户程序执行时间和10%的垃圾回收时间
Epsilon
JDK11引入,无操作GC,只做内存分配,不做垃圾回收,只适用于一次性运行完一小段程序就退出的场景
ZGC
JDK11引入,可伸缩的低延迟垃圾回收器,侧重低延迟,ZGC的设计目标是在对吞吐量不造成大影响的前提下实现任意堆大小情况下都将垃圾收集的暂停时间限制在10ms内,ZGC也基于Region分区算法,不设置分代,使用读屏障、染色指针和内存多重映射等技术实现可并发的标记压缩算法
官方文档对ZGC的介绍https://docs.oracle.com/en/java/javase/12/gctuning/
ZGC工作过程的四个阶段并发标记-并发预备重分配-并发重分配-并发重映射都是并发的,只在初始标记阶段进行STW,停顿时间几乎就消耗在初始标记上
测试数据
在保证暂停时间不超过10ms的条件下ZGC的吞吐量略低于Parallel,略高于G1,只有1%-2%的差距;如果不保证暂停时间不超过10ms,ZGC的吞吐量相较于Parallel和G1有将近50%的提升
在暂停时间方面,ZGC吊打parallel和G1,平均和99%暂停时间只有1-2ms,即使是最大暂停时间也能控制在10ms内,而parallel和G1的平均暂停时间都在200-300ms之间,G1的平均暂停时间和parallel差不多,但是在最差表现比parallel差很多,G1适合大堆场景
ZGC的测试数据相当亮眼甚至达到革命性的要求,未来会作为服务端大内存低延迟应用的首选垃圾收集器
JDK14中ZGC被扩展到可以在macOS和windows上使用,此前只能在Linxu上使用,在mac或windows上使用ZGC需要配置JVM参数-XX:+unlockExperimentalVMOptions -XX:+UseZGC,前一个参数表示解锁实验性的JVM参数
Shenandoah
RedHat公司开发,OpenJDK12引入,侧重低延迟,第一款不由Oracle公司领导开发的HotSpot垃圾收集器,商业版的OralceJDK不愿意引入别人的垃圾收集器,Oracle号称openJdk和oracleJDK没有区别,结果免费开源的openJDK竟然功能还比oracleJDK更多,Shenandoah在2014年被RedHat捐赠给openJdk
Shenandoah团队号称Shenandoah暂停时间与堆大小无关,不管将堆设置成多少都有99.9%的把握把垃圾收集的暂停时间限制在10ms内,但是实际使用性能还是取决于堆的大小和工作负载;2016年红帽使用ElasticSearch对200GB的维基百科数据进行索引并发表论文数据:暂停时间确实相较于其他垃圾收集器有质的飞跃,在其他垃圾收集器总停顿时间达到十秒左右的情况下总停顿只有320ms,在其他垃圾收集器的最大停顿时间都是1-4秒的情况下只有89.79ms,平均停顿时间在其他垃圾收集器450-850ms的情况下只有53ms,但是没有实现停顿时间在10ms内,在吞吐量相较于其他垃圾收集器出现了明显下降,以下是测试数据
低延迟方面很厉害,但是吞吐量也断崖式下跌,ZGC性能上比Shenadoah更好一点
JDK12新特性尚硅谷视频对Shenandoah有进一步介绍
其他厂商的垃圾收集器
AliGC:TaobaoJVM使用的是Ali自己开发的AliGC,基于G1的算法面向大堆场景的垃圾收集器,特定场景下停顿时间比G1要短
ZingGC:https://www.infoq.com/articles/azul_gc_in_detail
概念简述
System.gc():
通过显式调用System.gc()[里面实际调用的就是Runtime.getRuntime().gc()]或者Runtime.getRuntime().gc()[这个方法是一个本地方法]触发的是Full GC,同时对堆和方法区进行垃圾回收,System.gc()调用附带无法保证对垃圾收集器绝对调用的免责声明,经过测试事实上是否执行也是随机的
但是在调用System.gc()以后再调用System.runFinalization()会强制确保调用失去引用的对象的finalize()方法
因为垃圾回收是自动的,因此一般不需要用户手动调用,但是在特殊场景下比如性能基准测试,在做测试前先做一个GC,防止测试过程中因为内存的原因对测试结果造成影响导致结果不准确
方法中的非静态代码块中定义的局部变量出了代码块仍然存在于局部变量表中,此时GC不会将对应的变量进行回收,按理说局部变量的作用域只在代码块内部,出了代码块局部变量表中的数据就应该被销毁;但是实际上需要后续变量复用局部变量表中失效变量的slot空间时将对应变量值覆盖掉引用才会断掉,此时此前已经结束的代码块中定义的对象才会被垃圾回收器回收
方法弹栈也会导致局部变量表中的引用断掉,此时对象没有引用指向也可以进行垃圾回收
Java层面的内存溢出和内存泄露:
GC一直在发展,一般情况下不太容易发生OOM,除非垃圾回收器的回收速度低于占用内存的增长速度
报OOM以前一定会进行一次独占式的Full GC
JavaDoc对内存溢出的解释:没有空闲内存,且垃圾收集器GC后也无法提供更多的内存
没有空闲空间主要有两个原因
JVM的堆内存设置的太小,通过JVM参数-Xms和-Xmx来调整
程序中创建了大量大对象,且长时间因为被引用不能被GC回收,像JDK6以前永久代理的字符串常量池回收不积极就容易发生永久代内存溢出,此时添加新依赖,运行时创建大量动态类,intern方法调用太随意,都很容易发生永久代的内存溢出;永久代替换成元数据区以后这个问题得到改善
报OOM以前一定会触发一次Full GC
清理死亡对象,尝试回收软引用指向的对象、NIO的API也会自动调用System.gc()清理空间,清理以后还是内存溢出就直接抛OOM异常
特殊情况下内存溢出JVM能确定调用垃圾收集器不能解决问题会直接抛OOM,不会再触发垃圾回收,比如超大对象超过堆的最大容量不会尝试进行垃圾回收而是直接抛OOM异常
内存泄漏的解释:对象不会再被程序用到,但是GC又不能回收的对象
实际情况中一般是因为一些不好的实践,比如像引用计算算法中的循环引用问题导致的泄露类似的情况导致的内存泄露完全是出于疏忽或者考虑不完全导致的,由比如将局部变量定义成成员变量或者类变量,将没有必要设置成会级别的数据设置成了会话级别;一般都是忘记断开引用
内存泄露的问题在于因为无法回收或者无法及时回收会慢慢增长占用的内存,直到所有内存耗尽
内存泄露举例:
单例模式:单例对象的生命周期和应用程序一样长,单例独享如果持有外部对象的引用,外部应用无法被回收从而产生内存泄露
提供close()方法执行关闭操作的资源:像网络连接和IO通道必须手动调用close()方法关闭,否则也会因为无法回收而导致内存泄漏
TLAB存不下新对象重新从伊甸园区为线程分配一块新的TLAB,旧的TLAB的剩余空间不会再继续使用,而且单个TLAB本身空间就比较小只占伊甸园区的1%还要除以线程总数,很容易就会出现剩余空间无法存放新对象的情况,TLAB的内存浪费现象比较严重,这会导致JVM运行过程中始终有一部分内存无法被使用,为此JVM使用最大浪费空间对TLAB进行约束,当TLAB剩余空间存不下新对象且剩余空间小于最大浪费空间,TLAB所属线程会向JVM申请一块新的TLAB区域存储新对象,如果新TLAB仍然存不下,对象会被直接分配到伊甸园区;如果当前TLAB的剩余空间大于最大浪费空间,对象会被直接分配到伊甸园区;默认最大浪费空间的JVM参数TLABRefillWastePraction为64,表示值为TLAB大小的1/64
STW:
GC过程中会产生停顿,停顿过程中所有的用户线程都会被暂停,效果就像应用程序卡死了
为什么需要STW:
可达性分析算法中枚举根节点需要暂停所有用户线程来保证分析前后数据的状态一致性,如果不暂停用户线程就可能导致分析过程中对象引用关系不断变化,无法保证分析结果的正确性
所有的垃圾回收器包括G1、CMS、ZGC都有STW事件,只能说随着垃圾回收器的发展,性能更优秀,停顿时间更短;以至于现在衡量一个垃圾回收器的两个指标就是吞吐量和低延迟
设置一个用户线程Thread.sleep()每隔1毫秒打印打印一次。当发生full gc时,打印间隔会有20-150ms的变化
垃圾回收的并行与并发:
线程并发[concurrent]:不是多人一起走,单人就是单核,单个核交替执行多个任务导致用户以为多个任务在同时进行
并发多个任务一起相互抢占资源
线程并行[Parallel]:多个人一起走,多人即多核,真正的多个任务同时刻一起执行
并行多个任务之间不会互相抢占资源
GC串行:单条垃圾收集线程串行工作,用户线程此时处于暂停状态,Serial和Serial Old是两个典型的串行垃圾回收器
GC并行:多条垃圾收集线程并行工作,用户线程此时处于暂停状态,ParNew、Parallel Scavenge、Parallel Old是三个典型的并行垃圾回收器
GC并发:垃圾回收线程和用户线程同时执行,但不一定完全没有STW,用户线程和垃圾回收线程也可能交替执行;CMS和G1是典型的并发垃圾回收器
安全点与安全区域:
安全点:用户线程只能在特定的位置才能停顿下来开始进行垃圾回收,这些位置就称为安全点,安全点就是线程指令中执行时间比较长的某些指令位置
安全点太少GC等待的时间太长,垃圾回收不及时;安全点太频繁可能导致运行时的性能问题
大部分指令的执行时间都非常短,在执行很快的指令处停下来也会影响性能,一般会选择执行时间较长的指令位置作为安全点,比如方法调用[压入栈帧比较耗时]、循环跳转、异常跳转等
GC时让线程停顿在安全点的方式
抢先式中断:先统一中断所有线程,某些线程不在安全点就恢复线程让线程继续执行到安全点,目前没有虚拟机采用抢先式中断
主动式中断:JVM设置一个中断标志,每个线程运行到安全点时就主动轮询该中断标志,如果中断标志为true,就将当前线程暂停
安全区域:
在程序执行的情况下,程序每运行很短一段时间就能遇到一个安全点,但是程序如果因为sleep处于Blocked状态,此时程序无法响应JVM的暂停请求,无法去安全点进行中断,JVM也不能等待线程被唤醒,此时可以通过安全区域解决该问题
安全区域是一段代码片段,在该代码片段中,对象的引用关系不会发生变化,因此在这段代码区域中的任何位置GC都是安全的
运行到安全区域的线程会被标识为Safe Region,发生GC时,JVM会忽略掉标识为Safe Region的线程;当线程即将离开安全区域时会检查JVM是否已经完成了GC,如果已经完成了GC会继续运行,否则线程会暂停等待直到收到可以离开安全区域的信号
引用:99%的场景都使用的强引用,类库或者框架源码中用到了软引用和弱引用,除强引用外其他三种引用都可以在包java.lang.ref下找到,都继承自抽象类java.lang.ref.Reference;以下特点都是在引用关系还在的情况下,如果引用关系已经不存在了即使是强引用关联的对象也会被回收;软引用和弱引用主要用于缓存场景;虚引用用于对象回收跟踪
强引用:
概念:类似Object obj = new Object()这种使用构造器创建一个新对象并将对象地址赋值给一个变量,这个变量就称为指向该对象的强引用;将一个强引用赋值给另一个变量对应变量也是强引用,任何情况下垃圾回收器都不会回收存在强引用关系的对象,强引用是创建对象默认的引用类型
凡是具有强引用关联的对象都是可达的,都处于可触及状态;对应的软、弱、虚引用关联的对象都对应软、弱、虚可触及状态,处于这三种状态的对象在一定条件下都可以被回收;强引用是造成Java内存泄漏的主要原因
软引用[SoftReference]:
存在一类对象,当内存空间足够时希望对象保留在内存中;内存空间垃圾收集后还是紧张则希望抛弃这些对象
系统将要OOM以前,会先将不可达对象进行回收,回收后如果发现内存还是不够就会将软引用关联的对象进行回收,回收后还是没有足够内存抛出OOM
软引用通常用来实现内存敏感的缓存,高速缓存就使用的软引用,Mybatis的源码中一些内部类就使用了软引用
软引用的创建,首先使用构造器如Object obj = new Object()声明一个强引用关联对象,使用软引用类关联的对象的构造器如SoftReference<Object> sf = new SoftReference<Object>(obj);创建一个软引用,然后使用obj=null销毁强引用,不销毁强引用软引用不会对GC造成影响;上述三行代码等价于SoftReference<Object> sf = new new SoftReference<Object>(new Object());一行代码;通过软引用的softReference.get()方法可以获取软引用封装的对象,如果对象已经被回收该方法返回null
软引用对象的回收可以指定一个软引用队列,通过该队列可以跟踪对象的回收情况
弱引用[WeakReference]:
只要进行垃圾回收,只被弱引用关联的对象就会被回收
弱引用对象的创建方式WeakReference<Object> weakReference = new new WeakReference<Object>(new Object());,也可以使用类似创建软引用的三行代码形式;也通过weakReference.get()来获取被软引用关联的对象,该对象被回收以后,weakReference.get()返回null
弱引用对象的回收可以指定一个弱引用队列,通过该队列可以跟踪对象的回收情况
像安卓系统的三级缓存,如果内存中存在图片就去内存中获取,内存中没有就去本地文件获取,本地文件没有就去网络中获取,内存中的图片缓存就可以使用软引用或者弱引用来关联,只在内存充足时保持缓存
集合WeakHashMap<K,V>和弱引用相关,使用WeakHashMap存储图片信息内存不足时就会及时地回收内存数据,WeakHashMap中的Entry<K,V>是WeakHashMap的内部类,继承了WeakReference<Object>实现了Entry<K,V>,注意WeakHashMap只有key使用的是弱引用,值使用的是强引用,因此WeakHashMap中的对象除了集合本身对key的弱引用外,key对应对象没有其他引用,集合会自动丢弃该键值对让键值对对应对象自动被垃圾回收
虚引用[PhantomReference、幽灵引用、幻影引用、幻像引用]:
为一个对象设置虚引用关联的唯一目的是对象在被垃圾回收器回收时收到一个系统通知,此外虚引用不会对对象的回收造成任何影响即不会影响一个对象的生命周期,同时用户也无法通过虚引用来获取一个对象的实例,虚引用的唯一目的是跟踪对象的垃圾回收过程
虚引用通过phantomReference.get()方法获取对象时总是获取的null,虚引用必须和引用队列一起使用,虚引用在调用构造器创建的同时必须提供一个引用队列作为参数如PhantomReference<Object> phantomReference = new PhantomReference<Object>(obj,referenceQueue<Object>);,用户可以自定义对referenceQueue中引用的自定义处理逻辑
垃圾回收器回收一个对象时如果发现对象还有一个虚引用,会在回收对象后将该虚引用加入引用队列来通知用户对象的回收情况
终结器引用[FinalReference]:
修饰词为缺省,包内可用,实际开发中一般不使用
用于实现对象的finalize()方法,终结器的构造方法也需要传入引用队列,终结器引用关联的对象在GC开始时,终结器引用也会入队列,由Finalizer线程通过终结器引用找到被引用的对象并调用该对象的finalize()方法,下次GC时对象被回收
OopMap
GC Roots枚举根节点的过程需要暂停用户线程,对栈进行扫描,对整个栈进行扫描很消耗性能,HotSpot采用了空间换时间的方法,使用OopMap存储栈上的对象引用信息,每个栈帧可能有多个OopMap,存储在栈帧的附加信息区域,GCRoots枚举根节点时直接通过遍历每个栈帧的OopMap找到栈上的根节点
OopMap中存储的数据
OopMap记录的是栈中某个寄存器或者栈帧的某个偏移量处存储了一个对象引用,OopMap中存储的是对象引用的位置
简述内存分配[就是对象分配策略]和回收策略、Minor GC、Major GC[Full GC]
对象分配基本过程
对象分配考虑内存怎么分配、在哪里分配,还要考虑对象被GC以后内存空间是否会产生内存碎片的问题
1️⃣:所有对象首先都被创建在伊甸园区,如果创建前发现伊甸园满了会触发一次YGC[也叫作Minor GC],通过可达性分析算法分析哪些对象成为垃圾应该被回收,没有被回收的对象转移到其中一个幸存者区中,注意JVM为每个对象都分配了一个年龄计数器age,将对象从伊甸园区转移到幸存者区age会变成1
伊甸园区满了会将伊甸园区和幸存者from区一起触发YGC
如果触发YGC以后伊甸园区没对象了还是放不下当前对象,说明对象是超大对象会直接尝试放在老年代,老年代放不下会触发FGC[也叫Major GC,其实FullGC和MajorGC还有细微区别],老年代放得下直接分配内存,老年代如果还是放不下虚拟机会尝试自动扩容堆区空间,如果堆区已经无法扩容了则会直接抛OOM
进行YGC也会判断幸存者区能否放下伊甸园区的对象,如果放不下伊甸园区的对象会直接晋升老年代,from区的对象如果转移到to区放不下也会直接晋升老年代
2️⃣:伊甸园区又满了再次触发YGC,此次幸存的对象将会转移到另一个没有对象的幸存者to区,同时触发上一次垃圾回收转移对象的幸存者from区的垃圾回收,没有被回收的对象也转移到幸存者to区,然后将to区改为from区,from区改为to区,即将空的幸存者区设置为to区,为下一次伊甸园区YGC做准备,每重新被转移一次幸存者区对象的age年龄计数器就会自增1,如果幸存者from区中的对象的年龄计数器达到默认阈值15,就会将年龄计数器达到15的对象转移到老年代,年龄计数器变成16
年龄计数器的阈值可以通过JVM参数-XX:MaxTenuringThreshold=<N>重新设置
幸存者区满了即使年龄计数器没有达到阈值也会直接晋升到老年代
有些情况也可能对象不经过幸存者区直接从伊甸园区晋升到老年代,比如幸存者区放不下伊甸园区的对象
垃圾回收频繁收集新生代,很少收集老年代,几乎不动永久代或元空间;80%的对象都在新生代被回收掉了
对象分配策略
所有对象优先分配到伊甸园区
大对象直接分配到老年代,因此要避免程序执行过程中创建过多的大对象
长期存活的对象会分配到老年代中
如果幸存者区中某个年龄中所有对象的内存占用超过幸存者区的一半,年龄大于等于该年龄的对象无需达到年龄计数器的阈值就可以直接进入老年代
YGC如果幸存者区放不下对象也会被转移到老年代
使用参数-XX:HandlePromotionFailure配置空间分配担保,伊甸园区GC以后剩余的对象容量仍然超过幸存者区,超出的对象会直接进入老年代
辨析MinorGC、MajorGC和FullGC
重点要考虑MajorGC和FullGC,因为这两种GC的暂停时间在MinorGC的十倍以上,很多人在面试中是说不清楚MajorGC和FullGC的;大部分的时候的垃圾回收都是新生代的垃圾回收
HotSpot的GC按照回收区域分为部分收集和整堆收集
部分收集:不是完整收集整个堆区的垃圾收集
新生代[Eden、S0、S1]垃圾收集MinorGC/YoungGC,MinorGC完全等价于YGC
Minor GC的触发机制:新生代的伊甸园区空间不足会触发MinorGC,幸存者区满了不会触发MinorGC,幸存者区满了会将幸存者区的所有对象都转移到老年代,MinorGC由于Java对象一般创建用了就会销毁因此执行非常频繁;
Minor GC会引发STW[即Stop The World,暂停所有用户线程,就类似于回收垃圾的时候就别扔垃圾了,并发垃圾回收器的主要特点就是收垃圾的同时还可以制造垃圾],垃圾回收完毕后再恢复用户线程的运行,因为新生代比较小,垃圾回收的速度比较快,STW的影响小
老年代垃圾收集MajorGC/OldGC,只有并发垃圾回收器CMS GC在MajorGC的时候才有单独收集老年代的行为,其他的垃圾回收器在进行MajorGC的时候不是单纯只收集老年代
通常在执行MajorGC以前会先执行一次MinorGC,如果MinorGC后空间还是不足才会触发MajorGC;但是并行垃圾回收器不会执行MinorGC而直接进行MajorGC
Major GC的执行速度一般比Minor GC慢10倍以上,STW的时间也更长
如果MajorGC以后老年代的空间还是不足就会报OOM,因此OOM以前一定会经过一次Full GC
混合收集MixedGC:收集整个新生代和部分老年代的垃圾,目前只有G1 GC才会有混合收集,主要原因是G1回收器以region作为单位进行垃圾回收,region在老年代和新生代都有
整堆收集Full GC:整个堆区和方法区一起进行垃圾收集,方法区满了也会触发Full GC
触发Full GC的几种情况:
调用System.gc()时会建议系统执行FullGC,系统会根据运行情况自行判断是否执行Full GC
老年代或者方法区空间不足
从YGC晋升老年代的对象平均大小大于老年代的可用内存
大对象直接进入老年代但是老年代的可用空间不足
Full GC在开发中要尽量避免,让STW的整体时间尽可能短
回收整个堆和方法区的垃圾的GC才叫Full GC,Major GC是回收老年代
触发FullGC的时机
YGC前老年代的可用连续空间小于前几次年轻代晋升老年代的平均大小会直接将YGC替换成Full GC,方法区空间满了时[概率小,因为方法区使用的是本地内存,空间很大]
调用System.gc()时会建议系统执行FullGC,系统会根据运行情况自行判断是否执行Full GC
混合回收完成前老年代的空闲空间已经被耗尽,此时就会使用Full GC暂停所有用户线程来进行兜底垃圾收集
堆是分配对象存储的唯一选择吗?简述一下逃逸分析技术
随着JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配和标量替换优化技术使所有对象都分配在堆上变得不那么绝对,但是由于逃逸分析带来的标量替换实际上只是将聚合量拆分成标量存储在栈中的过程,而且JDK1.7以后将常量池和静态变量也放在堆,因此严格意义上堆还是分配对象的唯一选择
如果一个对象经过逃逸分析后发现并没有逃逸出方法,该对象就可能被优化成栈上分配内存,无需在堆上分配内存,也无需进行垃圾回收,这也是最常见的堆外存储技术
基于OpenJDK深度的定制的TaoBaoVM,使用GCIH技术可以将生命周期较长的对象分配到堆外,垃圾回收不会考虑GCIH内部的对象,从而降低GC的频率提升GC的效率
逃逸分析技术:
逃逸分析是一种减少Java程序中堆内存分配压力的分析算法,通过逃逸分析JVM编译器能分析出一个新对象引用的使用范围并以此为根据决定是否要将该对象分配到堆上
如果一个对象在方法中被定义后,只在该方法内部使用,该对象被认为没有发生逃逸;一旦被外部方法引用,就认为该对象发生了逃逸;一个对象只要在作为返回值被返回,不管事实上是否被接收使用都算发生了逃逸,总之一个对象只要没有被其他方法使用的可能该对象就没有发生逃逸,就可能在虚拟机栈上分配存储,随着栈帧弹栈就一同释放掉了
发生逃逸的各种情况:
方法中创建的对象存在被外部方法调用的可能[作为方法返回值返回、创建的对象赋值给实例变量或者类变量]
方法2通过调用方法1获取的对象a即使对象没有逃出方法2的范围仍然认为对象a发生了逃逸
在JDK 6u23版本后,HotSpot默认开启了逃逸分析[可以通过参数-XX:-DoEscapeAnalysis关闭逃逸分析],更早的版本需要使用参数-XX:+DoEscapeAnalysis显示开启逃逸分析,可以通过JVM参数-XX:+PrintEscapeAnalysis查看逃逸分析的筛选结果
编译器针对逃逸分析对代码可以做出的优化
栈上分配:没有发生逃逸的对象会被首选分配到栈上,局部变量对象随着栈帧弹栈被一起回收,无需进行垃圾回收
不开启逃逸分析,创建一千万个对象需要耗费77ms,此外堆内存较小的情况下会发生GC;开启逃逸分析,创建一千万个对象只需要4ms,堆空间变小了也不会发生GC
同步省略:动态编译同步块时,JIT即时编译器可以借助逃逸分析判断同步代码块使用的锁对象是否只能被一个线程访问,如果当前对象没有发布到其他线程,JIT即时编译器在编译同步代码块的时候会取消这部分代码的同步,避免不必要的性能消耗,提高系统并发能力,这个过程就是同步省略,也叫锁消除;JVM自动将没必要加的锁去掉,但是在字节码文件中仍然能看到同步锁上锁和解锁的monitorenter和monitorexit指令
标量替换[分离对象]:标量替换实际上是为栈上分配提供基础,在Java中一个引用数据类型实例即还可以分解成标量的数据是一个聚合量,聚合量中的基本类型实例变量就是一个标量,一个聚合量可以拆分成若干标量和子聚合量,子聚合量还可以继续拆分为多个孙标量,如果一个实例经过JIT即时编译器逃逸分析后发现没有发生逃逸,这个原本在堆开辟空间存储的聚合量可以替换成只在栈空间开辟存储空间的若干标量,通过标量替换的方式实现栈上分配;
标量替换可以通过JVM参数-XX:+EliminateAllocations开启标量替换,默认就是开启的,只有开启标量替换才允许将对象大三分配到栈上
标量替换的前提是一个对象可以被拆成标量,如果不能拆比如一个没有属性的对象,还是在堆区存储
开发中方法能使用局部变量就尽量不要使用在方法外定义对象,能用局部变量就不要使用实例变量或者类变量,局部变量不仅能高效GC还能直接被分配到栈空间[分配到栈空间多线程创建对象还不需要竞争同一块堆空间]
必须在JVM的Server模式下才能启用逃逸分析,Server模式需要JVM参数-server开启,即逃逸分析在服务端才有得,在客户端没有逃逸分析,64位操作系统上启动的默认就是Server模式
逃逸分析的论文在1999年就发布了,直到JDK1.6才有实现,至今技术也不是特别成熟,根本原因是逃逸分析本身也是一个相对复杂耗时的过程也需要消耗性能,无法保证逃逸分析的性能消耗低于在堆分配存储的消耗,而且经过逃逸分析以后如果对象发生了逃逸这个逃逸分析过程就被浪费掉了,虽然不成熟但是也是即时编译器优化技术中的重要手段;对于非堆分配,淘宝的GCIH将对象放在本地内存中不考虑垃圾回收是目前比较成熟应用的方案,只要加内存就能提高系统性能;HotSpot也没有应用直接在栈上分配对象的方案,使用的是标量替换来实现栈上分配的效果;JDK7以后字符串常量池和静态变量也不会放在永久代和元空间上,而是直接分配到堆上;因此目前还是认为堆空间是对象实例分配的唯一空间
发生OOM的情况
OOM对应的是OutOfMemoryError,Throwable下有两个子类Error和Exception,Exception是狭义上的异常,Throwable是指整个JVM运行不正常出现"异常"情况,OOM发生的最多的还是堆空间,一般的解决手段是通过内存映像分析工具如Eclipse Memory Analyzer、jvisualvm或JProfile对堆转储快照即dump文件进行分析,确认内存中的对象是否必要,分析到底是出现了内存泄漏[始终有引用指向某个对象,但是该对象实际上已经不使用了]还是内存溢出问题,如果dump文件比较小就要考虑是不是程序运行期间比如NIO用到了直接内存导致直接内存相关的区域出现OOM问题
如果是内存泄漏,使用上述工具查看具体泄漏对象到GC Roots的引用链,通过泄漏对象和GC Roots引用链的信息定位出泄露代码的位置,即时把引用给断掉
如果是纯粹的内存溢出,即存活的对象都确实还必须活着,应当检查虚拟机的堆参数判断是堆还是方法区出现的OOM,检查堆或者方法区的内存是否还可以增大,检查某些对象的生命周期是否过长[没必要静态的不要定义成静态,只在某个方法内使用的就只定义成局部变量]
虚拟机栈创建或者自动扩容时发现没有额外的内存可以创建或者扩容虚拟机栈
老年代Full GC后发现老年代仍然放不下对象
元空间的最大内存一般设置为-1即与系统内存保存一致,但是本身系统内存有限,或者堆内存分配过大,导致直接内存不足,从而没有足够的直接内存来装载所有的类也会直接抛出OOM异常
不管用JProfile还是Jvisualvm都是无法直接监测到直接内存的占用的,想要监测可以任务管理器查看系统内存的变化
当JVM进程使用NIO或者其他方式操作直接内存时,开辟直接内存空间时直接内存不足,比如手动设置了直接内存上限或者系统本地内存不足,在开辟直接内存的具体代码处也会抛出OOM异常
Java对象创建过程
创建对象的方式
new 构造器,私有的构造器可以通过类的静态方法来调用创建对象,比较常见的是XxxBuilder/XxxFactory的静态方法来调用私有构造器创建对象
class.newInstance():通过反射的方式,只能调用被public修饰的空参构造器,该方法在JDK1.9被弃用
constructor.newInstance(Xxx):通过反射的方式调用无参或者有参构造器,对构造器的权限修饰符没有限制,因此更加灵活,因此把Class.newInstance()废弃了
clone():当前类实现Cloneable接口,实现其中的clone()方法,实际上Cloneable接口是一个标识接口,不带任何方法,重写的是Object中的clone()方法,通过该方法可以实现一个已有对象的复制,是对对象的浅拷贝[即创建一个相同类型的新对象,如果属性值是基本数据类型新对象直接赋值老对象属性的值,如果属性值是引用数据类型新对象直接赋值老对象属性的引用地址]
反序列化:通过反序列化可以从文件或者网络中获取一个对象的二进制流,使用二进制流还原成一个对象
第三方库如Objenesis:使用相关字节码技术动态生成构造器对象
创建对象流程
new指令检查目标对象类型是否已经被类加载,在堆空间开辟出对应的对象空间,根据属性类型确定对象总共占用的字节数,对所有属性值做临时初始化
new指令会首先区检查该指令的参数能否在元空间的常量池中定位到一个类的符号引用,并检查该符号引用代表的类是否已经被类加载,如果没有进行过类加载,在双亲委派模式下使用当前类加载器以类加载器+全限定类名为key查找对应的字节码文件,如果没有找到文件抛出ClassNotFoundException异常,找到进行类加载生成对应的Class对象
dup指令将操作数栈栈顶的对象引用[这个是等待赋值的目标对象]复制一份压入操作数栈作为调用该对象相关方法的句柄
invokespecial调用目标类型的构造器初始化对象实例
astore_1将对象实例从操作数栈取出保存到局部变量表中
对象创建的六个步骤[以下六个步骤都执行完了才认为对象被完整创建出来了,创建对象的代码在字节码指令中被拆分出了好几行,new Object()被拆成new、dup、invokespecial,标准上是执行完构造器Object()也就是invokespecial对象才算完整被创建出来,但是对象的结构在new指令执行完就被创建好了]
判断当前创建的对象对应的类是否已经被类加载,没有类加载先进行类加载
计算对象占用空间大小,在堆区为对象划分一块内存,属性中根据对应类型计算字节数,如果是引用数据类型,保存对应的对象地址占用四个字节,分配内存要考虑堆空间内存是否规整;具体采用哪种内存分配方式主要看对空间内存是否规整,是否规整由垃圾收集器是否带有压缩整理功能决定
如果堆空间内存规整,JVM使用指针碰撞法来为对象分配内存,指针碰撞法说的高大上,实际上就是规则的内存就类似于向栈中压数据,指针始终指向栈顶,数据不停压入栈顶,JVM如果选择基于标记整理算法的串行Serial以及并行ParNew即带有整理过程的垃圾收集器都会采用这种对象内存分配方式
如果堆空间内存不规则[已使用的内存和未使用的内存相互交错],JVM需要维护一个空闲列表使用空闲列表法来为对象分配内存,空闲列表记录着哪些内存块可用哪些不可用,为对象分配内存时从列表上找到一块足够大的空间分配给对象实例并更新列表,空闲列表法对应的基于标记清楚算法的垃圾回收器比如并发CMS[GC以后内存不是规则的]
处理并发安全问题,多线程在堆区创建对象涉及到共享对象可能会出现线程安全问题
使用CAS失败重试或者加锁保证更新操作的原子性
每个线程预先分配一块TLAB,JDK8默认是开启的,可以使用JVM参数-XX:+/-UseTLAB来设定
对分配内存的空间的所有属性进行默认初始化,保证对象实例字段在未赋值的情况下可以直接使用
默认初始化[零值初始化]:为指定类型变量赋默认值,变量在没有显示初始化的情况下Java会为变量分配默认值,这个过程叫隐式初始化
显示初始化/代码块初始化:显示初始化是变量在创建时就通过赋值语句指定初始值,代码块初始化像静态代码块一样但是不需要加static关键字,可以给实例变量赋值
构造器中初始化
设置对象的对象头信息:依次在对象头中记录对象的所属类即指向方法区的类元数据信息、对象的HashCode,对象的GC信息、锁信息,不同的JVM实现具体的设置信息不同
执行init方法初始化对象:正式调用构造器初始化成员变量并将堆内对象的首地址赋值给声明对象处的引用变量,对应着字节码指令中的invokespecial指令,在这个步骤中会对属性进行显示初始化、代码块初始化或者构造器初始化
显示初始化、代码块初始化和构造器初始化代码对应的字节码指令都会组合到构造器的<init>方法中
简述Java对象结构
对象头Header:包含运行时元数据MarkWord和类型指针
运行时元数据markWord:存储哈希值、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳
类型指针:指向方法区的类元数据即InstanceKlass[JVM创建一个InstanceKlass结构来存储类元信息],由此可以确定对象所属类型,注意指向的是类元数据不是class对象,注意不是所有的对象都会保存类型指针
如果是数组对象,对象头还会记录数组的长度
实例数据Instance Data:
存储当前类代码中定义以及从父类继承下来的各种类型的字段
存放规则为先存放父类中定义的变量,再存放子类中定义的变量;相同宽度的字段总是被分配在一起;如果参数CompactFields=true,子类的窄变量可能插入到父类变量的空隙,默认该参数就为true
字符串引用指向堆中的字符串常量池中的某个对象
对齐填充Padding:
没有特殊含义,就是起一个占位符的作用,据弹幕说是为了补齐8字节倍数,增加CPU的读取效率,据说和disruptor有关,可能存在也可能不存在
JVM如何通过对象引用访问到堆中的对象实例即对象两种访问定位方式[句柄和直接指针]
创建对象是为了使用对象中的一些功能或者对对象做指定操作,通过局部变量表中的保存对象地址的引用变量来对对象进行访问,具体访问对象的方式分为句柄访问和直接指针;JVM规范并没有明确指出具体实现需要采用哪种方式,不同的虚拟机实现采用的具体方式不同,HotSpot VM采用的是直接指针的方式
句柄访问:堆空间开辟了一块称为句柄池的空间,句柄池中的句柄保存了两个信息,一个是指向堆区对象实例数据的指针,一个是指向方法区对象类型数据的指针;虚拟机栈的局部变量表的引用指向句柄池中句柄的指向对象实例数据的指针,先找到句柄再通过句柄指针找到对象实例
缺点是效率比较低,而且还需要专门在堆区开辟一块空间保存对象的句柄信息
优点是虚拟机栈中的地址引用始终指向句柄池中的句柄,即使堆中的对象位置发生了改变[比如标记整理算法,幸存者区的相互转移,GC过程引起的对象转移],也只需要更改堆中句柄的对应值,而不需要修改虚拟机栈中局部变量表的地址引用,因此栈空间中的对象引用地址会非常稳定
直接指针:虚拟机栈的局部变量表中的引用直接指向堆中的对象实例数据,对象实例数据中对象头的类型指针保存着指向方法区该对象的类型数据
优点是对象访问速度快,缺点是一旦对象位置变化就需要改变虚拟机栈中相关地址引用的值,而且如果一个对象同时被多线程共享还要改很多地方,但是使用句柄访问只需要修改一处
String类和常量池,八种基本类型的包装类和常量池
字符串常量池以前存在方法区,后来存放在堆空间
概念:
Java通过同一份字节码文件通过不同平台上的JVM翻译成对应平台的机器指令来实现跨平台
JVM不和包括Java在内的任何语言绑定,只与特定二进制文件格式的字节码文件关联,任何语言只要最后编译成正确的字节码文件就能在JVM上运行,也是这个原因让JVM能够成为跨语言的平台,JDK每个版本都会发布一份java语言规范,同时额外出一份JVM规范,就是考虑到JVM是一款跨语言的平台
因为JVM都遵守JVM规范,用户写的Java程序编译出来的字节码文件可以在各种不同的JVM中运行
前端编译器的任务是将符合java语法规范的Java代码转换成符合JVM规范的字节码文件,javac是JDK自带的前端编译器,经历词法解析、语法解析、语义解析、生成字节码四个步骤可以将Java源码编译成字节码文件
Javac的特点是全量编译,全量编译的意思是每次编译都把Java源码完全重新编译一次;HotSpot没有要求必须使用Javac来编译生成字节码,Eclipse的前端编译器ECJ[Eclipse Compiler for Java]内置在Eclipse中,是一种增量式编译器,每次Ctrl+S时进行编译,且只会编译Java源码中更新的内容,已经编译过的内容不会再进行编译,ECJ的编译速度比javac快,编译质量和Javac是差不多的,这也是用Eclipse启动项目比IDEA快的原因;同时Tomcat也使用的是ECJ来编译JSP文件,ECJ基于GPLv2开源协议开源,在Eclipse官网可以下载ECJ的源码
此外还可以使用AspectJ编译器替代Javac,但是需要自行去下载并进行配置
前端编译器不会对代码进行优化,即使使用不同的前端编译器也不会对代码性能造成差异,代码优化主要由JIT编译器负责
字节码文件是源代码经过前端编译器编译生成的二进制类文件,字节码的内容就是JVM指令,C和C++是直接通过编译器生成机器码指令,任何一个字节码文件都对应唯一一个类或者接口的定义,字节码文件是一组以八位字节为基础单位的二进制流,字节码文件也不一定以磁盘文件的形式存在,也可以是网络中的二进制流数据,字节码文件本质上就是二进制流数据
字节码文件没有任何分隔符号,字节码文件中的字节顺序、字节数量、字节的含义都需要严格限定,不用分隔符号是为了压缩字节码文件的大小
字节码文件由表或者无符号数构成,无符号数就是特定长度[u1表示当前无符号数占1个字节,u2表示占用两个字节]表示特定含义的二进制码;表的长度不确定,表的前面会使用无符号数表示表的长度,表中存放常量池、当前类实现的所有接口、字段、方法、属性等信息;整个字节码文件相当于一个表,其中的每个无符号数或者表相当于字节码文件这张大表中的每个元素,每个元素的含义、次序、占用字节长度都是限定或者显示指明的,很像通信协议
字节码指令:字节码指令由一个字节长度的操作码和其后此操作需要的零个或多个操作数构成,很多操作码都不需要操作数,操作码和操作数之间使用空格分隔
IDEA会反编译字节码文件,但是因为编译时不会编译注释信息,因此反编译无法获取源文件中的注释信息
解读Class文件的方式
1️⃣:在IDEA中安装jclasslib插件,将java源码进行编译,将光标放在目标类上,View--Show Bytecode With Jclasslib,Jclasslib也有独立的客户端,安装了jclasslib客户端字节码文件可以自动使用jclasslib打开
2️⃣:使用Notepad++打开字节码文件和EditPlus打开效果是一样的,但是Notepad++可以安装插件HEX-Editor,在Nodepad++中插件--HEX-Editor--View in HEX会将乱码的二进制类文件展示为16进制码,用户需要自己分析每个字节代表什么操作码,操作码后面跟的是什么操作数
3️⃣:打开软件Binary Viewer,直接把字节码文件拖进该软件,就能看到和Notepad++一样的16进制码,只是信息量比Notepad++大一些,比如十六进制码对应的二进制或者十进制码是多少
4️⃣:使用JDK自带的javap工具可以反解析字节码文件,使用命令javap -v 字节码文件名.class > 文件名.txt将字节码文件的反解析结果输出到txt文件中
类文件结构
官方文档:https://docs.oracle.com/javase/specs/jvm/se8/html/jvms-4.html,这也是JVM规范中第四章的内容
字节码文件结构随着JDK迭代会有调整,比如指令的添加或者移除,但是基本框架和结构非常稳定,因为要考虑向下兼容的问题

魔数
u4表示用四个字节表示魔数,魔数作为字节码文件的标识,字节码文件通过魔数十六进制的cafebabe来识别,如果字节码文件的模数不为cafebabe会报错ClassFormatError
字节码文件版本
版本依次包含minor_version和major_version,各占两个字节,分别表示小版本/副版本和大版本/主版本,主版本.副版本构成了字节码文件的格式版本号,这个版本号和JDK的大版本有对应关系,45.3对应JDK1.1,此后JDK每升级一个大版本,主版本从45加1分别为45、47...[每个主版本减去44就是JDK的版本],副版本均为0
高版本的JVM可以执行有低版本编译器生成的字节码文件,低版本JVM不能执行高版本编译器生成的字节码文件即高版本的JVM只能解释运行低版本的字节码文件,否则报错java.lang.UnsupportedClassVersionError,开发环境和生产环境使用的JDK版本可能不同,要实现JVM对编译后的字节码文件版本向下兼容,避免出现该问题
常量池
常量池包含两部分,两个字节的常量池长度constant_pool_count也叫常量池计数器,常量池constant_pool表cp_info;如果常量池的长度为10,实际常量池表的长度为9,索引0没有分配数据
常量池是字节码文件中内容最丰富的区域,常量池中存储着字节码解析时字节码文件中存储字段名、方法名、方法的返回值类型、方法的形参;常量池是字节码文件的基石,随着JDK的迭代,常量池中的数据也会进行调整
常量池表用于存放编译时期生成的各种字面量和符号引用,常量池被类加载以后存放在方法区的运行时常量池中
常量池计数器表示常量池表项的数量、实际上常量池表项的数量为常量池计数器值减1即constant_pool_count-1,之所以少一个是将索引为0的位置空出来,目的是为了在属性、方法、字段引用常量池中的索引时在特殊场景下就不引用任何一个常量池表项,此时就通过引用索引值0来表示
常量池主要存放字面量和符号引用两大类常量
字面量:字面量包含文本字符串[就是字符串]和声明为final的常量值[比如final int NUM = 10]
符号引用:符号引用主要包含三种情况,类和接口的全限定名、字段的名称和描述符[描述符指字段的类型]、方法的名称和描述符[描述符指方法的形参和返回值类型],符号引用可能引用符号引用,但是符号引用最终指向的是字符串字面值
全类名指比如com.earl.commom.Product,全限定名指比如com/earl/common/Product
简单名称指不包含类型和修饰符的方法名或者字段名,就是方法名和字段名
描述符指描述字段的数据类型、方法的参数列表[包含数量、类型和次序]、返回值类型
基本数据类型和void都使用大写字符表示,引用数据类型统一用L加对象的全限定名来表示;一维数组用[表示,二维数组用[[表示
| 标识符 | 数据类型 |
|---|---|
| B | byte |
| C | char |
| D | double |
| F | float |
| I | int |
| J | long |
| S | short |
| Z | boolean |
| V | void |
| L | 引用数据类型,比如Ljava/lang/Object;为了表示区分使用分号结尾 |
| [ | 一维数组double[][][]三维double数组对应[[[D |
动态链接是指虚拟机运行时,从常量池中获取符号引用加载到运行时常量池,在类加载过程的解析阶段将符号引用图换位直接引用并且指向具体内存地址的过程
符号引用:使用一组唯一的符号[就是类和接口的全限定名、字段方法的名称和描述符]来描述被引用的目标,符号引用与虚拟机的实现的内存布局无关,引用的目标也不一定已经加载到内存当中
直接引用:直接引用可以是指向目标的指针、相对偏移量或者一个能简介定位到目标的句柄;如果一个符号引用被替换成了直接引用,那说明被引用的目标肯定已经存在于内存中了
标识15、16、18是JDK7引入的,体现Java对动态语言的支持;常量池表项可能是下面14种类型中的任意一个,常量池表项的顺序不是按下表顺序,每一个表项都会通过标识位表示当前表项的类型
每个常量池表项的第一个字节都是标识位tag,除了字符串每个常量池表项的字节数都是固定的,符号引用通过常量池的索引指向其他符号引用或者字符串字面值,但是最终还是指向字符串字面值
常量池表中只会出现int、float、long、double四种基本数据类型的常量,常量类型为byte、short、char、boolean都是使用int类型来装配的
编译器字符串字面量的长度是不确定的,编译后才确定字符串字面量的长度,因此字符串需要额外使用一个字段表示字符串的长度
常量池可以理解为字节码文件的资源仓库,一个类的所有类型、方法、属性的名字和描述都在常量池,后续很多数据类型以及字节码的非常量池结构都会指向字节码的常量池,是字节码文件空间占用最大的数据项,Java编译时JVM还没有启动,选择在JVM加载字节码的时候进行动态链接,字节码文件不会保存方法、字段的最终内存布局信息、因此只能使用符号引用来表示方法和字段,在运行期通过类加载和动态链接将符号引用转换得到真正的内存入口地址
| 类型 | 标识 | 描述 |
|---|---|---|
CONSTANT_utf8_info | 1 | UTF-8编码的字符串这个就是使用改进过的 UTF-8编码的字符串除了字符串标识还会使用两个字节记录字符串长度 一个字节代表一个字符 |
CONSTANT_integer_info | 3 | 整型字面量 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
CONSTANT_Long_info | 5 | 长整型字面量 |
CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
CONSTANT_Class_info | 7 | 类或接口的符号引用 一个字节的标识位 两个字节指向常量池中类对应全限定名的字符串字面值的索引 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 第一个单字节标识符 第二个双字节指向字段所在类的符号引用 CONSTANT_Class_info在常量池表中的索引 |
CONSTANT_Methodref_info | 10 | 类中方法的符号引用,包含一个字节的标识符 两个字节指向方法所在类的符号引用 CONSTANT_Class_info在常量池表中的索引两个字节指向方法名和类型的符号引用 CONSTANT_NameAndType_info在常量池表中的索引 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法名的符号引用 第一个是标识符 第二个是方法或者变量名字的对应字符串字面值在常量池表的索引 第三个是字段或者方法的形参类型列表和方法返回值类型对应字符串字面值在常量池表的索引 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_MethodType_info | 16 | 标识方法类型 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
常量池表项细节



一个字符串常量池示例
第一个CONSTANT_Methodref_info的内容为0X0a00040012,
其中0004就是所在类的符号引用为索引为4的常量池表项即CONSTANT_Class_info,内容为0x070015,其中0015指向常量池中索引为21的字符串字面量CONSTANT_utf8_info,内容为0x0100106a617661216c616e672f4f626a656374,对应的字符串为java/lang/Object;
0012是指向名称和类型的描述符CONSTANT_NameAndType_info在常量池表项中的索引12,内容为0x0c00070008,其中0007表示名称字符串字面量即CONSTANT_utf8_info在常量池中的索引为7,内容为0x0100063c696e,对应字符串为<init>;0008表示该方法的返回值名称和形参列表字符串字面量即CONSTANT_utf8_info在常量池表中的索引为8,内容为0x010003282956,对应字符串为()V,其中()中是形参列表,V是返回值类型,只是此处没有形参也没有返回值
第二个CONSTANT_Fieldref_info的内容为0x0900030013,
其中0003是字段所在类为索引为3的常量池表项CONSTANT_Class_info,内容为0x070014,0014指向常量池表索引为20的字符串字面量,内容为0x010016636f6d2f617467756967752f6a617661312f44656d6f,对应字符串为com/atguigu/java1/Demo
0013是指字段对应的名字和类型是索引为19的常量池表项CONSTANT_NameAndType_info,内容为0x0c00050006,其中0005表示字段名在字符串常量池中索引为5的对应字符串字面值,内容为0x0100036e756d,对应字符串为num;0006表示字段对应类型在字符串常量池中索引为6的对应字符串字面值,内容为01000149,对应字符串为I,表示当前字段的类型为基本数据类型int
[示例代码]
xxxxxxxxxxpackage io.renren.test;
public class Test { private int num = 1;
public int add() { num = num + 2; return num; }
}[字节码文件]

[常量池表项]

[常量池表项1]
类名对应常量池表项4,对应的字符串字面值为java/lang/Object;
方法的名字和形参类型列表、返回值类型对应常量池表项18,18又指向7和8,对应的字符串字面值为<init>:()V

访问标志access_flags[访问标志、访问标记]
两个字节,表明当前字节码是一个类还是一个接口,类或者接口的权限修饰符是什么[是否被定义为public、abstract或者final]
只要是一个类[非接口]都会带ACC_SUPER访问标识,使用ACC_SUPER可以让类更准确地通过super.method()定位父类的方法,现代编译器都会使用该标识
注解除了设置ACC_ANNOTATION外也需要设置ACC_INTERFACE访问标识
两个字节的访问标识是由所有的标志名称对应标志值做16进制加法得到的,比如0x0021是由0x0001和0x0020相加得到的,即说明类被public修饰
有ACC_INTERFACE标识表明当前是一个接口或者注解,没有说明是一个类;设置了ACC_INTERFACE同时必须设置ACC_ABSTRACT;同时不能设置ACC_FINAL、ACC_SUPER、ACC_ENUM
没有设置ACC_INTERFACE的字节码文件除了不能设置ACC_ANNOTATION可以设置其他任何访问标志
ACC_ENUM标志表明当前类或者其父类为枚举类型

Java中一个类只能被权限修饰符public或者缺省修饰,但是内部类可以被全部四种权限修饰符修饰
public:内部类可以被任何其他类访问
protected:内部类可以被同一包中的类以及所有子类访问
无修饰符(默认,也称包级私有):内部类只能被同一包中的类访问
private:内部类只能被其外部类访问
类索引
当前类的全限定类名,两个字节,指向的是常量池中对应值的索引的常量池表项中,是一个CONSTANT_Class_info,指向字符串字面值就是对应类的全限定名
父类索引
当前类的父类的全限定类型,两个字节,指向常量池中对应值的索引的常量池表项中,是一个CONSTANT_Class_info,指向字符串字面值就是当前类的父类的全限定名
Object的字节码对应父类索引位置值为0000,表示Object没有父类
接口索引集合
包含两部分,当前类实现的接口数量interfaces_count,占两个字节;
实现的接口表,接口表竟然也是u2占两个字节,这里u2应该是错的,老师说是一个表,表中的每一个表项都是常量池中的一个CONSTANT_Class_info,源码中实现接口的顺序和表中表项的顺序是一致的,表的索引i从0开始,0<=i<interfaces_count
字段表集合
包含两部分,字段数量fileds_count,占两个字节,表示当前字节码文件字段表的字段个数,也叫字段计数器;字段表fields,类型是field_info表
字段表包含类变量和实例变量,不包括代码块内部或者方法内部声明的局部变量;字段的名字和类型编译前无法确定,只能通过引用常量池中的常量来描述;字段表指向常量池中的索引,描述每个字段的标识符、访问权限修饰符、是类变量还是实例变量、是否是常量
字段表集合不会列出从父类或者实现的接口中继承来的字段,但是可能列出原本Java代码中不存在的字段;比如内部类为了保持对外部类的访问性,会自动添加指向外部类实例的字段
Java中不支持字段的重载,一个类中的字段不能重名[类型和权限不同仍然不能重名],但是同一个字段表中两个字段的名字相同但是描述符不一致[权限修饰符或者字段类型不同],字段表仍然认为这两个字段合法
每个字段表项都是一个field_info结构,field_info结构如下
访问标志:访问标志和类的访问标志一样也是由几个修饰符对应标志值求和得到,可选的标志名称如下;访问标志由两个字节构成
字段名索引:两个字节,指向常量池中对应字段名的字符串字面值在常量池中的索引,内容就是字段的名字
字段描述符索引:两个字节,字段描述符的作用是描述字段的数据类型,指向常量池中对应数据类型的字符串字面值在常量池中的索引,内容就是字段的类型,注意这个字段描述符直接就指向字符串字面值,而不是指向常量池中的CONSTANT_NameAndType_info或者CONSTANT_Fieldref_info
字段的属性计数器:两个字节,表示属性集合中的元素数量,属性集合中的每个元素都是一种字段属性,不同字段属性的结构不同,以常量的常量属性为例,结构为
attribute_name_index是属性名的索引,指向常量池中字符串字面值的索引,常量属性的属性名索引对应字符串的内容是ConstantValue;attribute_length是属性长度,常量字段的属性长度恒为2;constantvalue_index是常量字段的值的索引,对应常量池中对应数据类型的字面量的索引,比如常量是int类型,这里constantvalue_index就是常量池中其中一个CONSTANT_Integer_info的索引
除了字段有属性信息,方法和当前类都有属性表记录其属性信息,这个属性和字段是两回事,注意区分
xxxxxxxxxxConstantValue_attribute{ u2 attribute_name_index; u4 attribute_length; u2 constantvalue_index;}方法表集合
包含两部分,方法表的长度methods_count即方法计数器,占两个字节,方法计数器;方法表methods,类型是method_info表
方法的数量除了类本身定义的方法还有额外构造方法<init>和<clinit>方法
methods中的每个表项都指向常量池中CONSTANT_Methodref_info,每一个方法表项都包含方法的访问权限修饰符信息,方法的返回值类型和方法的参数信息,如果方法不是本地方法,方法表项中也会有放方法代码对应的字节码指令
方法表中只包含当前类或者接口中声明的方法,不包含从父类或者父接口继承来的方法;此外方法表中还可能出现由编译器自动添加的方法,典型的就是编译器产生的类或接口的<clinit>初始化方法和实例的初始化<init>()方法
Java语言要求方法的重载要求同一个类中两个同名方法具有不同的特征签名,特征签名就是一个方法的形参在常量池中字段符号引用的集合,方法的返回值类型并不包含在特征签名中,这意味着Java语言方法重载必须形参列表不一样;但是在字节码文件中,只要两个方法的描述符不完全一致这两个方法就是合法共存的,这意味着,两个方法有相同的名称和特征签名即形参列表完全相同,但是只要方法返回值类型不同,这两个同一个类的形参列表相同返回值不同的同名方法也是合法的
方法表项method_info结构,和字段表项field_info的结构基本上是一样的
访问标志:两个字节,同样由下面一个或者多个标志值求和得到
方法名索引:两个字节,保存指向字符串常量池中字符串字面值的索引,字符串内容就是方法名
描述符索引:两个字节,指向常量池中保存当前方法的形参列表和返回值类型信息的字符串字面值的索引,而不是指向常量池中的CONSTANT_NameAndType_info或者CONSTANT_Methodref_info
属性计数器:两个字节,表示属性表项的个数
属性集合:方法代码对应的字节码指令保存在属性表项中、此外属性表项还会保存方法的异常信息、LineNumberTable以及LocalVariableTable等属性信息
方法表的Code属性的格式[方法表的属性集合中有Code属性]
属性名索引:两个字节的属性名索引,对应字符串为Code
属性长度:四个字节,当前属性除了属性名索引和属性长度后续内容占用的字节数
操作数栈的最大深度:两个字节
局部变量表的存续空间:就是局部变量表的长度,两个字节
字节码指令的长度:四个字节,表示方法代码对应字节码指令占用的字节数
字节码指令:每个操作码占一个字节,每个字节码指令对应操作码可以通过jclasslib直接跳转官方文档查询;操作数如果是符号引用占用两个字节保存的是常量池表项索引,注意如果是invokespecial的操作数,操作数是一个方法的符号引用CONSTANT_Methodref_info;putfield指令的操作数是一个属性的符号引用CONSTANT_Fieldref_info,在字节码文件中占用两个字节,还行啊,十个字节能表示八条字节码指令
异常表长度:两个字节,方法没有异常该长度为0
异常表:
属性表计数器:Code属性中还可以有属性表、两个字节表示Code属性的属性表中元素的个数
属性表:
Code属性的属性表中第一个属性的属性名为LineNumberTable,第二个属性的属性名为LocalVariableTable

[LineNumberTable指令代码行号映射表的格式]:指明字节码指令偏移量与Java源程序代码的对应关系
两个字节的属性名索引attribute_name_index;
四个字节的属性长度attribute_length,指当前属性不包含属性名索引和属性长度的剩余字节数
两个字节的line_number_table_length,表示有几个大括号表示的结构体
两个字节的start_pc和两个字节的line_number,记录的是字节码指令行号和Java源码行号的对应关系[两个大括号结构体分别表示方法的字节码指令开始行号、结束行号与Java代码行号的对应关系],字节码的行号专业术语称为偏移量
xxxxxxxxxxLineNumberTable_attribute{ u2 attribute_name_index; u4 attribute_length; u2 line_number_table_length; { u2 start_pc; u2 line_number; } line_number_table[line_number_table_length];}[LocalVariableTable的格式]
两个字节的属性名索引attribute_name_index;
四个字节的属性长度attribute_length,指当前属性不包含属性名索引和属性长度的剩余字节数
两个字节的local_variable_table_length,表示有几个大括号表示的结构体,就是表示有几个局部变量
两个字节的start_pc指局部变量声明的字节码指令行数
两个字节的length表示从start_pc行号开始,局部变量的作用域长度
两个字节的name_index表示局部变量的名字对应字符串字面值在常量池中的索引
两个字节的descriptor_index表示局部变量的类型对应字符串字面值在常量池中的索引
两个字节的index表示当前局部变量在局部变量表中所在槽位的索引
xxxxxxxxxxLocalVariableTable_attribute{ u2 attribute_name_index; u4 attribute_length; u2 local_variable_table_length; { u2 start_pc; u2 length; u2 name_index; u2 descriptor_index; u2 index; } local_variable_table[local_variable_table_length];}属性表集合
包含两部分,属性表的长度attributes_count即属性计数器,占两个字节,表示属性表中属性表项的个数;属性表attributes,类型是attribute_info表,每个属性表项都是一个attribute_info结构
上面的字段就是类的属性,这个属性是指字段的属性,比如当前类的类名、方法字节码的LineNumberTable和LocalVariableTable等信息;字段的属性用于存储额外的信息,比如初始化值[初始化值只有常量才有]、注释信息等;以常量字段的常量属性信息为例,常量属性的结构为
xxxxxxxxxxConstantValue_attribute{ u2 attribute_name_index; u4 attribute_length; u2 constantvalue_index;}整个字节码结构的最后一个结构是一个属性表,在字节码的其他结构比如字段表和方法表的表项也可能是一个属性表,字节码文件这个大表结构中的表项属性表存放的是字节码文件携带的辅助信息,比如class字节码文件对应源文件的名称、类上是否带有RetentionPolicy.Class或者RetentionPolicy.RUNTIME表明生命周期的注解,这些信息一般用于JVM的验证、运行和Java程序的调试,不需要太关注
字段表和方法表也有自己的属性表,存储常量的属性信息、方法的字节码指令、异常信息等;属性表项没有严格的顺序,设置只要属性表项的名字不同用户可以向属性表项中写入自定义的属性信息,但是JVM运行时如果发现不认识的属性表项会自动忽略
attribute_info结构通用格式:
JDK8中一共有23种属性,不同属性的结构不一样,属性的通用格式为
属性名索引两个字节指向常量池中的字符串字面值,比如方法表的Code属性,
属性长度刻画的是属性表项除了属性名索引和属性长度外占用的字节数
认为所有的属性都含有属性名、属性长度和属性表三个结构
| 类型 | 名称 | 数量 | 含义 |
|---|---|---|---|
u2 | attribute_name_index | 1 | 属性名索引 |
u4 | attribute_length | 1 | 属性长度 |
u1 | info | attribute_length | 属性表 |
常见属性格式[属性的结构要通过属性名去JVM规范的第4.7章]
SourceFile的格式
两个字节的属性名索引对应的常量池字符串字面值为SourceFile
四个字节的属性长度,这个值官方文档固定要求就是2
源码文件索引:两个字节指向常量池中的字符串字面值,对应的内容为带后缀名的源文件比如Demo.java
| 类型 | 名称 | 数量 | 含义 |
|---|---|---|---|
u2 | attribute_name_index | 1 | 属性名索引 |
u4 | attribute_length | 1 | 属性长度 |
u2 | sourcefile_index | 1 | 源码文件索引 |
Integer x=5和int y=5有什么区别
Integer和int使用==判断是否相等会将Integer自动拆箱拆成基本数据类型和int类型的值进行比较
[Integer x=5字节码指令]
向操作数栈压入数据5
调用Integer.valueOf静态方法,传参就是操作数栈中的5,获取Integer对象
将Integer对象存入局部变量表索引为1的位置
xxxxxxxxxx0 iconst_51 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>4 astore_1[Integer.valueOf方法]
IntegerCache是Integer中的静态内部类,IntegerCache中有一个Integer数组类型的常量,数组长度为127-(-128)+1=256,然后从int类型的-128开始自增,循环向数组中调用Integer的单参int构造器创建Integer对象从索引0开始存入数组中
如果valueOf方法的入参在-128-127之间,直接从integerCache的入参+128索引处获取Integer对象;如果入参不在这个范围内就使用Integer的单参构造方法实例化Integer对象
xxxxxxxxxxpublic static Integer valueOf(int i) { if (i >= IntegerCache.low && i <= IntegerCache.high) return IntegerCache.cache[i + (-IntegerCache.low)]; return new Integer(i);}[int x=5的字节码]
向操作数栈压入5
将5存入局部变量表中索引为2的位置
xxxxxxxxxx 5 iconst_5 6 istore_2分析如下代码的结果[父类构造方法调用不会显式初始化以及属性根据引用类型的静态绑定]
[代码]
xxxxxxxxxxclass Father { int x = 10;
public Father() { print(); x = 20; }
public void print() { System.out.println("Father.x = " + x); }}
class Son extends Father { int x = 30;
public Son() { print(); x = 40; }
public void print() { System.out.println("Son.x = " + x); }}
public class SonTest { public static void main(String[] args) { Father father = new Son(); System.out.println(father.x); }}[执行结果]
这个结果光从代码层面上来解释是不行的,就是把书翻烂了也很难解释,需要从字节码层面分析,弹幕说这是静态分派和动态分派,周的那本书上有
xxxxxxxxxxSon.x = 0Son.x = 3020[分析流程1]
在Father的构造器调用之前,成员变量x进行了默认初始化0和显式初始化10,构造器中初始化20,在构造器初始化前调用了print()方法打印被显示初始化成员变量的值10
成员变量的赋值过程:默认初始化0--显式初始化/代码块中初始化--构造器中初始化--对象实例化以后通过对象.属性或对象.方法的方式对成员变量进行赋值
构造器初始化完成后属性被赋值20,此时father.x就是20
xxxxxxxxxxclass Father { int x = 10;
public Father() { print(); x = 20; }
public void print() { System.out.println("Father.x = " + x); }}
class Son extends Father { int x = 30;
public Son() { print(); x = 40; }
public void print() { System.out.println("Son.x = " + x); }}
public class SonTest { public static void main(String[] args) { Father father = new Father(); System.out.println(father.x); }}[执行结果]
xxxxxxxxxxFather.x = 1020[Father构造器字节码]
首先调用父类Object的构造器
然后进行Father中成员变量的显式初始化
显示初始化以后执行Father的构造方法开始调用print方法,然后在构造器赋值x=20
xxxxxxxxxx 0 aload_0 1 invokespecial #1 <java/lang/Object.<init> : ()V> 4 aload_0 5 bipush 10 7 putfield #2 <io/renren/test/Father.x : I>10 aload_011 invokevirtual #3 <io/renren/test/Father.print : ()V>14 aload_015 bipush 2017 putfield #2 <io/renren/test/Father.x : I>20 return[分析流程2]
xxxxxxxxxxclass Father { int x = 10;
public Father() { print(); x = 20; }
public void print() { System.out.println("Father.x = " + x); }}
class Son extends Father { int x = 30;
public Son() { print(); x = 40; }
public void print() { System.out.println("Son.x = " + x); }}
public class SonTest { public static void main(String[] args) { Father father = new Son(); System.out.println(father.x); }}[执行结果]
xxxxxxxxxxSon.x = 0Son.x = 3020[Son构造器字节码]
调用Son的构造器首先会调用父类Father的构造器,调用Father的构造器,注意此时调用父类构造器时父类构造器执行前不会像直接调用父类构造器一样会在构造器执行前先使用父类定义的成员变量对成员变量显式初始化,此时只有子类的成员变量被默认初始化,然后直接调用父类的构造方法只会执行父类构造方法里的代码,父类构造方法执行完子类成员变量再执行子类的构造方法,此时子类构造方法本身执行以前会利用子类的成员变量定义信息对子类成员变量进行显式初始化;一句话子类成员变量的显示初始化在父类构造器调用后在当前类构造器调用前执行,父类构造器执行时不会进行显示初始化
父类构造器执行前不会进行显式初始化,因此在进行父类构造器赋值前先打印,此时打印的是默认初始化的值0
父类构造器执行结束后此时子类成员变量被父类构造器赋值20,然后子类构造器被调用,子类构造器执行前先进行当前类成员变量的显式初始化赋值30,然后执行子类构造器先打印成员变量的值30,然后进行子类构造器初始化40
为什么最后打印实例化后的子类成员变量的值是20而不是40呢,这是因为大多数面向对象的编程语言中,属性的访问是静态绑定而不是动态绑定,编译阶段编译器会根据引用的类型而非对象的实际类型来决定访问引用类型对应的属性值,这意味着如下示例
静态绑定指编译时确定被调用的代码、动态绑定指运行时根据对象的实际类型来调用代码
xxxxxxxxxxclass Animal { String name = "Animal";}
class Dog extends Animal { String name = "Dog";}
public class Main { public static void main(String[] args) { Animal animal = new Dog(); System.out.println(animal.name); //"Animal" System.out.println(((Dog)animal).name);//"Dog" }}xxxxxxxxxx 0 aload_0 1 invokespecial #1 <io/renren/test/Father.<init> : ()V> 4 aload_0 5 bipush 30 7 putfield #2 <io/renren/test/Son.x : I>10 aload_011 invokevirtual #3 <io/renren/test/Son.print : ()V>14 aload_015 bipush 4017 putfield #2 <io/renren/test/Son.x : I>20 returnJavap工具
概述:javap是jdk自带的字节码文件解析工具,作用是根据字节码文件解析出当前字节码文件的版本号、访问标志、常量池表、方法表的code属性、局部变量表、异常表、代码行偏移量映射表等信息;不显示当前类索引、父类索引,接口索引集合等结构;<clinit>()和<init>()被反编译成对应的静态代码块和构造器
通过局部变量表,用户可以查看局部变量的作用范围、所在slot槽位信息以及槽位复用的情况
注意使用javac xx.java编译源码生成的字节码中不会包含局部变量表的信息;如果使用javac -g xx.java编译才会在字节码文件中生成局部变量表信息[使用javap指令反解析也会少局部变量表信息];默认情况下,Eclipse、IDEA编译时默认会生成局部变量表、指令和代码行偏移量映射表等信息
javac -g xx.java相较于javac xx.java就是每个方法的Code属性中都多了一个LocalVariableTable属性表,此外常量池中多了几个常量池表项比如字符串LocalVariableTable,建议手动编译的时候加上参数-g
指令语法:javap <options> <classes>,其中<options>表示javap命令可以使用一些参数,<classes>表示要反解析的字节码文件的名字。
可设置的<options>参数,多个参数可以组合一起使用比如javap -s -p Demo.class,字节码文件不带class后缀也是可以的
-version:显示当前javap所在jdk的版本信息,不是指当前字节码文件是在哪个jdk版本下编译生成的
-public:仅显示当前字节码文件中修饰符为public的公共字段、构造器和方法
-protected:仅显示当前字节码文件中修饰符为public和protected的字段、构造器和方法
-p/-private:显示当前字节码文件中所有的字段、构造器和方法以及静态代码块,不包含非静态代码块
-package:显示当前字节码文件中所有非私有的字段、构造器和方法以及静态代码块,不包含非静态代码块
-sysinfo:显示当前字节码文件的系统信息,比如字节码文件的绝对路径、最近的修改时间、占用字节大小、MD5散列值、源文件的文件名,此外还会显示当前字节码文件中所有非私有的字段、构造器和方法以及静态代码块,不包含非静态代码块
-constants:显示常量的最终值,前面展示常量只展示结构不展示值,该参数也会显示当前字节码文件中所有非私有的字段、构造器和方法以及静态代码块,不包含非静态代码块;同时常量还会带上值
-s:显示当前字节码文件中所有非私有的字段、构造器和方法以及静态代码块,不包含非静态代码块;同时常量还会带上值;同时显示字段或者方法的描述符[即类型和形参类型列表]
-l:显示当前字节码文件中所有非私有的字段、构造器和方法以及静态代码块,不包含非静态代码块;方法会带上行号映射表和局部变量表;如果编译后的字节码文件中不包含局部变量表,该指令不会显示局部变量表
-c:显示当前字节码文件中所有非私有的字段、构造器和方法以及静态代码块,不包含非静态代码块;同时显示方法的Code属性即方法对应的字节码指令,只想看字节码指令使用-c
-v/-verbose:显示字节码文件的版本信息,访问标识,常量池、字段和方法的描述符信息、方法的Code属性,代码行号映射表、局部变量表等;这显示的是字节码文件最全的信息,注意不包含私有的字段、构造器和方法,需要包含私有字段和方法可以使用组合指令javap -v -p Demo.class
静态代码块在前端编译器编译时会转换成<clinit>方法存储在字节码文件中,使用javap解析的时候会被自动将<clinit>标识为static{}
一个方法的执行会涉及到虚拟机栈中的局部变量表和操作数栈、堆中对象、常量池、帧数据区[栈帧除了局部变量表和操作数栈的方法返回地址、动态链接和一些附加信息]和方法区
字节码指令
官方字节码指令文档https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html
概述:
JVM的指令由一个字节长度、代表特定操作的字节码和其后的零至多个操作需要的参数即操作数构成,一些简单的操作数被融入到操作码一体比如a,因为操作码的长度为一个字节,因此操作码总数不会超过256条,实际上字节码指令总共约200多条
字节码的执行模型:
1️⃣:自动让PC寄存器的值加1
2️⃣:根据PC寄存器的指示位置从字节码流中取出操作码
3️⃣:如果取出的操作码存在操作数,从字节码流中依次取出对应数量的操作数
4️⃣:执行操作码定义的操作
5️⃣:如果字节码流还有数据循环1️⃣-4️⃣直到字节码流长度为0
一些指令与数据类型强相关,比如aload_0其中的a就表示引用数据类型,0表示局部变量表中索引为0的位置;iconst_1其中的i表示int类型,数据类型在字节码指令中对应的字符i-int、l-long、s-short、b-byte、c-char、f-float、d-double
一些字节码指令的助记符没有明确指明操作数据类型,比如arraylength,但是要求操作数只能为一个数组类型,即没有指定操作码的字节码指令也隐含操作数的类型
byte、char、short、boolean不被指令支持,byte、short类型数据会在编译期或者运行期将byte和short转换成带符号扩展的int类型[就是有正有负的int类型,因为byte和short也可能有正有负],将boolean和char转换成零位扩展的int类型数据[就是只有正的int类型];对应着局部变量表中的一个Slot槽对应四个字节;处理byte、char、short、boolean数组也会转成int数组来进行处理
指令可以从局部变量表、常量池、堆中对象、方法调用、系统调用中获取值数据或者对象引用数据并压入操作数栈,也可以从操作数栈中取一个到多个值完成赋值、加减乘除、方法传参和系统调用等操作
指令带load、push、const、ldc的都是将数据压栈到操作数栈中;指令带store都是将操作数栈中的数据保存在局部变量表中
加载与存储指令[使用频率最高]:这类指令的作用就是将数据在常量池、局部变量表和操作数栈之间来回传递
局部变量压栈到操作数栈
1️⃣:xload,这个指令需要操作数,向操作数栈中压入局部变量表索引为操作数的数据,比如iload 0和iload_0效果是一样的,其中x可选的值为i、l、f、d、a;
2️⃣:xload_<n>,其中x可选的值为i、l、f、d、a;n的可选值为0-3;这种指令不需要显示声明操作数,实际上操作数已经隐含在指令中了,意思是从局部变量表中索引为n的位置取出数据压入操作数栈中,n设置为0-3是因为这几个指令使用的比较频繁,字节码指令占用空间少[增加了指令数量,减少了字节码的体积],超出0-3的范围还是要使用xload指令显式声明索引
32位JVM局部变量表中引用数据类型占4个字节;64位JVM默认会在堆内存小于32G时启用压缩指针,引用大小为4个字节,如果显示禁用,引用大小为8个字节;如果堆内存大于32G,引用大小为8个字节
字节码解析的局部变量表中被复用的slot可能不会显示,但是Misc中的Maximum local variables会显示所有局部变量的个数
常量压栈到操作数栈:将常量压入操作数栈,根据数据类型分为const、push、ldc系列指令
1️⃣:const系列
| 指令格式 | 功能 | 备注 |
|---|---|---|
iconst_<i> | 将int类型常量压入操作数栈 | i∈[-1,5]0...5指的是数据,不是索引值为 -1的时候指令为iconst_m1 |
lconst_<l> | 将long类型常量压入操作数栈 | l∈[0,1],指数据 |
fconst_<f> | 将float类型常量压入操作数栈 | f∈[0,2],指数据 |
dconst_<d> | 将double类型常量压入操作数栈 | d∈[0,1],指数据 |
aconst_null | 将null压入操作数栈 |
2️⃣:push系列:当int类型数据超过[-1,5]的范围时需要使用bipush和sipush来向操作数栈压入int类型数据
bipush:将范围在[-128,127]的int类型数据压入操作数栈,bipush只能接收8位整数作为操作数[b可以认为byte表示一个字节]
sipush:将范围在[-32768,32767]的int类型数据压入操作数栈,sipush只能接收16位整数作为操作数[s可以认为short表示两个字节]
3️⃣:ldc系列:如果int类型数据范围超过[-32768,32767],可以使用ldc指令
ldc:ldc指令接收一个8位参数,该参数指向常量池中的int、long、double、float、String的索引,JVM会根据索引找到数据并压入操作数栈;long、double只要不是0和1,float不是0、1、2,这三种数据类型就会使用ldc指令向操作数栈压入数据[操作数栈中存放字符串是存放的字符串对象在堆内存中的引用];此外ldc的操作数还可以是字符串对象的索引[类型不同值相同的常量在常量池中的符号引用不同]
ldc_w:向操作数栈中压入int或float类型数据时,如果常量池中的目标常量索引太大,会自动切换ldc_w指令,支持16位参数;如果索引比较小都会使用ldc指令,jvm会根据索引找到常量值压入操作数栈
ldc_2w:向操作数栈压入double类型数据时,只要不是0或者1,需要使用常量池中的数据,会直接使用ldc_2w指令,jvm会根据索引找到常量值压入操作数栈
操作数栈数据弹栈装入局部变量表
1️⃣:xstore:其中x的可选值为i、l、f、d、a,需要提供一个byte类型的操作数指定局部变量表存储数据的槽的索引,注意float占用四个字节对应局部变量表中的一个槽
2️⃣:xstore_n:其中x的可选值为i、l、f、d、a;其中n的可选值为0-3的整数,表示局部变量表的槽slot的索引;指令的功能是从操作数栈中弹出指定类型的数据保存到局部变量表中索引为n的槽
处理局部变量表访问索引的指令[没讲]
算术指令
对操作数栈上的两个变量值做特定运算,再将结果重新压入到操作数栈,大多数算术指令都是操作的两个变量值,只有像类似取反这种只操作单个变量值
凡是涉及到byte、short、char、boolean类型及其数组类型的数据运算,都储存为int类型使用int类型的指令来处理
运算时溢出:两个很大的正整数相加结果可能是一个负数,但是JVM并没有对溢出数据进行处理,只规定了除法和求余指令出现除数为0抛出ArithmeticException异常,注意不论short和char的值是多少,加法运算结果总是int类型,其他的运算方式没测;如果是两个int类型相加,运算溢出确实会得到负的结果
运算模式:
向最接近的数舍入模式:浮点数计算时,就是浮点数要四舍五入到指定的精度,如果精度的下一位正好是5且是最后一位,则摄入到最接近的偶数,这叫四舍六入五考虑,Java使用的向最近接的数舍入模式就使用的是四舍六入五考虑的模式
向零舍入模式:浮点数转换为整数时都是直接从小数点截断取小数点前面的整数
NaN值:算术操作的结果没有明确数学定义也会使用NaN来表示并且返回NaN
使用int类型除以0会抛ArithmeticException异常,但是使用double j = 10/0.0;[其中10是int类型]会显示Infinity表示无穷大;使用double d = 0.0 / 0.0会得到结果NaN,如果两个int类型的数据相加溢出可能会变成负数
1️⃣:加法指令xadd,其中x可以是i、l、f、d
2️⃣:减法指令xsub,其中x可以是i、l、f、d
3️⃣:乘法指令xmul,其中x可以是i、l、f、d
4️⃣:除法指令xdiv,其中x可以是i、l、f、d
5️⃣:求余指令xrem,其中x可以是i、l、f、d[取余单词对应remainder]
6️⃣:取反指令xneg,其中x可以是i、l、f、d[取反单词对应negation]
整数-1从二进制上来说全是1,使用-1和一个整数做位异或运算可以将一个数取反
7️⃣:自增指令iinc
i += 10运算符会使用自增指令如iinc 2 by 10,如果i为byte类型使用byte = i+10会编译报错,但是使用i+=10就不会报错
注意iinc 1 by 1是将局部变量表中索引为1的变量值加1,不是操作操作数栈中的变量
int类型比如i++会使用iinc指令,但是double类型或者是short类型比如d++则是加载d到操作数栈,指定常量1,两者相加再存入局部变量表;short类型即使使用int替换也不会使用iinc指令,因此实际开发中建议这种i++自增的优先选择int类型
只有局部变量的i++和i--才会使用iinc指令,如果是成员变量即使数据类型是int也不会使用iinc指令而是压入数据和常量1使用xadd或者xsub来进行自增或者自减操作,iinc指令只会对局部变量表中的int类型才会生效
位运算指令
1️⃣:位移指令:ishl、ishr、iushr、lshl、lshr、lushr
该指令有两个操作数,第一个操作数是要进行移动的数据,第二个操作数是要移动的位数,以上指令分别对应左移、右移、无符号右移[无符号右移是不保留符号位将所有位向右移动,在左侧填充0,溢出取剩余部分]
2️⃣:位或指令:ior、lor
3️⃣:位与指令:iand、land
4️⃣:位异或指令:ixor、lxor
比较运算指令[比较栈顶两个元素的大小,并将比较的结果存放在操作数栈中]
1️⃣:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
d、f、l分别针对double、float、long类型数据
cmpg/cmpl:两个指令的功能都是弹出操作数栈栈顶的两个元素依次为V1,V2;如果v1==v2压入0,如果v1>v2压入-1;如果v1<v2压入1;只有像double和float类型数据因为NaN的存在,如果遇到NaN,压入操作数栈的结果可能不同,如果指令是fcmpg遇到NaN会压入1,如果是fcmpl遇到NaN会压入-1;
long型整数没有NaN值,所以不需要准备两套指令
比较指令使用一般会结合控制转移指令一起使用
类型转换指令
类型转换指令可以将两种的不同数值类型相互转换[基本数据类型除了boolean类型以外都是数值类型],类型转换指令分为宽化类型转换和窄化类型转换,宽化指int转换为float;float转换为int会称为窄化类型转换,类型转换操作一般用于实现用户代码中的显示类型转换操作,或者用来处理字节码指令中数据类型相关指令无法与数据类型一一对应的问题
1️⃣宽化类型转换:指数据类型从小范围类型向大范围类型转换,这种转换不需要在Java代码中进行强转操作,自动通过编译生成对应类型转换的字节码指令,助记int ---> long ---> float ---> double前后顺序不变两两排列,如long l=i转换得到的新值会重新写入到局部变量表的新槽中,int类型转换成long类型新存入局部变量表的数据会占用两个槽,转换前只占用一个槽;
int类型转换成long、float、double类型对应的字节码指令依次为i2l、i2f、i2d
long类型转换成float、double类型对应的字节码指令依次为l2f、l2d
float类型转换成double类型对应的字节码指令为f2d
🔎:正常从int转换为long,int转化为double不会发生精度损失,转换前后的值精确相等;但是从int、long转换到float,或者long转换为double可能会发生精度损失,可能丢失掉最低有效位上的值,转换后的浮点数值根据IEEE754按照向最接近舍入模式java中对应四舍六入五考虑的方式得到正确整数值;这是因为long类型占8个字节,float类型占4个字节,float一部分表示底数,另一部分表示指数,因此可以存储的范围更大,但是会导致数据的精度不够高;Java中涉及到高精度数据需要使用BigDecimal,精度损失比如下例,数据从123123123变成了123123120发生了精度损失,当数据比较小的时候不一定会发生精度损失,比如例test2;同时注意这种转换永远不会抛出运行时异常
xxxxxxxxxxpublic void test1(){ int i = 123123123; float f = i; System.out.println(f);//1.2312312E8}
public void test2(){ long i = 123123123123L; double d = i; System.out.println(d);//1.2312312E11}注意byte、char、short类型数据的转换也同样是全部当做int类型处理并使用int转换的相关指令,这三种类型本身就会全部当做int处理;对这三种数据类型的处理主要是考虑到节省指令数量,因为不愿意让操作码占用字节数超过一个字节,同时后续可能还有新的指令加入,比如JDK7就新加入了invokedynamic指令,因此最大限制256的基础上还要有一些空余;同时局部变量表中的槽固定为32位,为了内存对齐直接将这三种数据类型当做int处理
2️⃣窄化类型转换:也叫强制类型转换,在Java代码中需要强转符,指数据类型从大范围类型向小范围类型转换;
int类型转换成byte、short、char类型对应的字节码指令依次为i2b、i2c、i2s
long类型转换成int类型对应的字节码指令依次为l2i
float类型转换成int或long类型对应的字节码指令依次为f2i、f2l
double类型转换成int、long或float类型对应的字节码指令依次为d2i、d2l、d2f
其他类型转换成byte、short、char类型会使用两条字节码指令先转换成int类型,再转换成byte、short、char类型
如果是short类型转成byte类型会直接当做int类型转成byte类型
🔎:容量大向容量小转换很容易出现精度损失问题,可能会导致转换结果具备不同正负号、不同数量级,但是窄化类型转换不会导致JVM抛出运行时异常
🔎:如果一个浮点值窄化转换为整数类型int或者long,如果浮点值是NaN,转换结果为0;浮点数不是无穷大按照IEEE754向零舍入模式取整,如果获取的整数在int或者long的可表示范围内,转换结果就是该整数,如果在int或者long的可表示范围外,就转换为int或者long可表示的最大或者最小范围
🔎:如果将一个double类型转换成一个float类型,如果原数据绝对值太小以至于无法使用float来表示,将返回float类型为正负零;如果原数据绝对值太大以至于无法用float来表示,将返回float类型的正负无穷大Infinity;如果double类型是NaN,将转换成float类型的NaN;可以通过double d = Double.NaN将double类型变量声明为NaN,通过double d1 = Double.POSITIVE_INFINITY声明成double类型的正无穷大,通过double negativeInfinity = Double.NEGATIVE_INFINITY;声明double类型的负无穷大
对象创建与访问指令
Java的对象是针对基于类或接口创建的实例、基于数组创建的实例
1️⃣对象创建指令:[因为Java创建对象的频率很高,因此这部分指令的使用频率也很高]
new:创建类实例的指令,接收一个指向常量池索引的操作数,指明创建对象对应类型,执行完以后将对象引用压入操作数栈
在堆开辟空间,类进行初始化并创建实例,该指令执行结束后会将对象地址引用存入操作数栈,创建类实例以后会执行dup指令在操作数栈将对象地址引用复制一份并保存在操作数栈中;然后调用类的构造器方法执行实例初始化此时栈顶的第一个对象地址引用弹出供方法调用根据对象地址找到对象;然后将对象引用存入局部变量表中
注意有参构造器通过invokespecial指令执行类实例初始化方法时会同时弹栈构造器形参和对象地址引用
newarray:创建基本类型数组
byte、short、char都会创建int类型数组
操作数是数组长度,一般数组长度都会先入操作数栈,然后随着创建数组的字节码指令执行被弹栈
anewarray:创建引用类型数组
操作数为类在常量池中的索引
multianewarray:创建多维数组
操作数为多维数组类型在常量池中的索引
注意new String[10][]通过字节码指令可以看到只会调用anewarray创建一个数组对象,数组对象的每个元素为String[],只是每个元素还没有初始化都是null,并不是直接通过多维数组指令创建这种只指定了数组元素个数没有指定每个元素的String个数的多维数组;如果是new String[10][5]此时就会先压栈数组两个数组长度然后弹栈调用multianewarray
2️⃣字段访问指令:通过对象访问指令可以获取对象实例或者数组实例中的字段或者数组元素
访问类变量
getstatic:把静态变量压入操作数栈中
比如System.out.println("hello")对应字节码
getstatic #8 <java/lang/System.out>:#8字段符号引用存储的信息为System类中out字段,字段对应的类型是Ljava/io/printStream;,调用该指令会将静态变量System.out的引用地址压栈到操作数栈;ldc #9是压入操作数栈常量池中的字符串字面值hello;invokevirtual调用常量池中方法的引用java/io/PrintStream.println,调用该方法时会从操作数栈弹栈out字段的引用地址和字符串字面值供方法调用使用
xxxxxxxxxx0 getstatic #8 <java/lang/System.out>3 ldc #9 <hello>5 invokevirtual #10 <java/io/PrintStream.println>8 returnputstatic:把静态变量从操作数栈中弹出
该指令接受一个常量池属性符号引用,从操作数栈弹栈属性值,将属性值放入类结构的name属性[因为属性值赋值给类变量,因此不需要操作数栈压栈再弹栈对象地址]
getfield:把实例变量压入操作数栈中
该指令接受一个常量池属性符号引用,去堆中获取对应属性并将属性值或者属性对象压栈到操作数栈
putfield:把实例变量从操作数栈中弹出
该指令接受一个常量池属性符号引用,从操作数栈弹栈属性值和属性地址,将属性值放入堆中对象的对应属性值中
3️⃣数组操作指令:
xastore:将操作数栈的元素值、数组元素的索引以及数组引用弹栈,将元素值存储到堆中数组对象的对应索引处,x的可选值为b、c、s、i、l、f、a,b同时表示对byte和boolean的操作,c表示对char数组的操作,s表示对short的操作
xaload:从操作数栈中依次弹栈数组元素索引和数组引用,根据这两个参数将一个数组元素值压栈到操作数栈中,x的可选值为b、c、s、i、l、f、a,b同时表示对byte和boolean的操作,c表示对char数组的操作,s表示对short的操作
将操作数栈中的值存储到byte和boolean数组元素中都使用baload指令
arraylength:弹出栈顶的数组引用,根据引用获取数组长度,将数组长度压入操作数栈
4️⃣类型检查指令:
instanceof指令:在使用多态如A a = new Dog()的时候,我们可能需要将父类型引用强转为子类型引用来调用子类特有的方法或者子类的属性;在进行强转前一般会用关键字instanceof来判断当前引用指向的对象是否是指定类型的实例,如果是就进行强转;instanceof指令的操作数为常量池中类型的符号引用,执行完后会将判断结果压栈到操作数栈
checkcast指令:该指令执行强制类型转换,同时会检查类型强制转换是否可以进行,如果可以进行,该指令不会改变操作数栈,否则会抛出ClassCastException;一般调用instanceof判断过强转就能够进行
方法调用与返回指令[实际代码中方法的调用频率也非常高]
1️⃣invokevirtual:调用对象的实例方法,根据对象的实际类型来调用方法,支持多态,只要方法有可能被子类重写
如果对象被强转成接口类型,再调用对应的实例方法,此时会使用invokeinterface指令,比如thread.run()会使用invokevirtual,但是((Runnable)thread).run()会使用指令invokespecial
2️⃣invokeinterface:通过接口类型引用调用子实现实例的方法,比如B实现了接口A,通过A a = new B(),通过a.方法名()调用方法时会使用invokeinterface指令
3️⃣invokespecial:special指进行特殊处理的实例方法,包括三种特殊方法,实例初始化方法[构造器]、私有方法、父类方法[父类方法调用会从当前类依次向上找其父类、父类的父类直到找到对应方法体],这些方法的特征是不可以被重写,这些方法都是通过静态类型绑定的,不会在调用时进行动态派发
静态类型绑定:指代码编译阶段编译器就能根据变量的声明类型确定调用的函数或者变量的地址,不考虑运行时变量的实际类型,运行时因为不需要额外的查找或者解析,因此执行效率比较高;缺点是灵活性低,无法实现真正的多态
动态类型绑定:也称为晚期绑定、运行时绑定或者多态[虚方法分派],指在运行时根据对象的实际类型来调用方法而不是变量的声明类型,动态类型绑定是Java实现多态使用父类类型的引用指向子类对象并调用子类重写的父类方法的关键机制和基础,允许子类覆盖父类的方法并在运行时动态地调用子类的版本;动态类型绑定在运行时确定调用的具体方法;动态类型绑定适用于除静态方法、私有方法、final修饰方法、实例构造器、通过super调用父类方法外的所有方法即虚方法;动态类型绑定因为运行时需要查找方法表因此执行效率相较于静态类型绑定略低
4️⃣invokestatic:调用类中的静态方法,静态方法也通过静态类型绑定,因为静态方法本身不会被子类重写
注意私有静态方法还是会使用invokestatic指令
调用接口的静态方法还是会使用invokestatic指令
5️⃣invokedynamic:这个没说,老师让自己了解
6️⃣方法返回指令
return:方法返回值类型为Void的方法、实例初始化方法、类和接口的类初始化方法对应指令均为return
ireturn:当返回值类型为int、short、char、byte、boolean类型时对应指令为ireturn
ireturn方法会将当前方法操作数栈顶部的元素弹出,将这个元素压入方法调用者的操作数栈中,然后丢弃当前方法的整个栈帧,恢复调用者栈帧的执行
xreturn:x的可选值为l、f、d、a
🔎:System.out.println(a+b)会先执行a+b的字节码指令,然后将a+b的结果存储到操作数栈中,然后调用System.out.println()方法时会生成新的栈帧并将a+b的和作为参数传入新栈帧的局部变量表
🔎:如果当前方法是被synchronized修饰的方法,方法返回指令执行前还会执行一个隐含的monitorexit指令释放锁
🔎:返回值类型和实际返回的值不同可能会自动进行宽化类型转换,比如
xxxxxxxxxxpublic float returnFloat(){ int i = 10; return i ;}操作数栈管理指令:提供像操作普通栈一样操作操作数栈的指令
pop:弹出操作数栈栈顶的元素,被弹出的数据直接废弃
注意操作数栈的基本单元是Slot槽,一般方法执行结束操作数栈还有数据会使用该指令
pop2:弹出操作数栈栈顶的两个元素,被弹出的数据直接废弃
弹出两个元素或者弹出一个占两个Slot的数据类型
dup:复制操作数栈栈顶数据一个Slot并压栈操作数栈栈顶,该指令调用比较多的是创建对象时复制引用地址供构造器进行初始化
dup2:复制操作数栈栈顶数据两个Slot并压栈操作数栈栈顶
dup_x1:将操作数栈栈顶一个Slot复制并插入到栈顶两个Slot的下面
dup_x2:将操作数栈栈顶一个Slot复制并插入到栈顶三个Slot的下面
dup2_x1:将操作数栈栈顶两个Slot复制并插入到栈顶三个Slot的下面
dup2_x2:将操作数栈栈顶两个Slot复制并插入到栈顶四个Slot的下面
swap:交换操作数栈栈顶的两个Slot
JVM没有提供交换两个64位数据类型的指令
nop:该指令的字节码为0x00,和汇编中的nop一样表示什么都不做,该指令一般用于调试占位
控制转移指令:对应Java中分支结构和循环结构的字节码指令
比较运算指令[比较栈顶两个元素的大小,并将比较的结果存放在操作数栈中]
1️⃣:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
d、f、l分别针对double、float、long类型数据
cmpg/cmpl:两个指令的功能都是弹出操作数栈栈顶的两个元素依次为V1,V2;如果v1==v2压入0,如果v1>v2压入-1;如果v1<v2压入1;只有像double和float类型数据因为NaN的存在,如果遇到NaN,压入操作数栈的结果可能不同,如果指令是fcmpg遇到NaN会压入1,如果是fcmpl遇到NaN会压入-1;
long型整数没有NaN值,所以不需要准备两套指令
比较指令使用一般会结合控制转移指令一起使用
条件跳转指令
1️⃣:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull;
这些指令接受两个字节的操作数用于计算指令跳转的位置,整体上功能为弹出栈顶元素,如果操作数栈栈顶的int类型数值满足某一条件跳转到给定位置;一般这些指令会和比较运算指令结合使用,通过比较运算指令先比较两个double、float、long类型数据的大小并根据比较结果向操作数栈压入int类型的-1、0、1;然后根据Java代码中使用的比较运算符确定使用上述哪种条件跳转指令比较int类型的-1、0、1和0的大小来决定后续跳转执行的代码
如果是一个int类型一个上述三种类型会先将int类型通过宽化类型转换转成long、float、double类型然后执行上述过程
如果是两个int类型比较大小跳转分支会使用比较跳转指令
如果是int类型和0比较大小会直接使用条件跳转指令
| 指令 | 功能 |
|---|---|
ifeq | 当栈顶int类型数值等于0时跳转 |
ifne | 当栈顶int类型数值不等于0时跳转 |
iflt | 当栈顶int类型数值小于0时跳转 |
ifle | 当栈顶int类型数值小于等于0时跳转 |
ifgt | 当栈顶int类型数值大于0时跳转 |
ifge | 当栈顶int类型数值大于等于0时跳转 |
ifnull | 当栈顶数值等于null时跳转 |
ifnonnull | 当栈顶数值不等于null时跳转 |
两个float比较大小会先将两个数压栈到操作数栈,然后调用比较运算指令比较两个数的大小,根据比较结果向操作数栈中压栈-1、0、1。根据原来比较运算符决定使用哪种条件跳转指令和0比大小,满足条件跳转到指定偏移量的字节码指令执行后续指令,如果不满足则继续向下执行并通过返回指令放弃后续指令的执行
System.out.println()有一系列重载方法,如果打印的结果是一个boolean类型,会调用参数为布尔类型的println(boolean)方法,boolean类型在JVM常量池中用Z表示,在字节码层面直接弹栈操作数栈中的int类型的1表示true,0表示false
比较条件指令
1️⃣:if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq、if_acmpne
功能:如果预设条件成立就进行跳转,否则执行下一条指令
以字符i开头的指令针对int类型整数操作[byte、short也当做int处理],以a开头的指令表示对对象引用的比较
以上指令均指针栈顶两个Slot类型的数值,前者指后一个弹栈的数值
数值压栈时第一个数就是前者,第二个数是后者,对java代码中的比较运算符取反,然后比较结果再取反就是实际的结果
| 指令 | 功能 |
|---|---|
if_icmpeq | 前者等于后者时跳转 |
if_icmpne | 前者不等于后者时跳转 |
if_icmplt | 前者小于后者时跳转 |
if_icmpgt | 前者小于等于后者时跳转 |
if_icmple | 前者大于后者时跳转 |
if_icmpge | 前者大于等于后者时跳转 |
if_acmpeq | 两引用类型数值相等时跳转 |
if_acmpne | 两引用类型数值不相等时跳转 |
多条件分支跳转指令:多条件分支跳转指令专为switch-case语句设计,此外给定值不匹配任何case值会跳转default
1️⃣tableswitch:用于case值连续的switch条件跳转[这里的连续指类似1234这种可以直接通过索引找到匹配的值],内部只存放起始值和终止值和若干跳转偏移量,通过给定的操作数index可以直接定位到跳转偏移量位置,效率比较高
2️⃣lookupswitch:内部存放着离散的case-offset对,在前端编译时会对case-offset针对case值进行排序,这也是一种优化;运行的时候效率会提高一些,每次执行都要搜索全部的case-offset对,找到匹配的case值,根据对应偏移量计算跳转地址,效率较低
如果有一个case分支没有break语句会直接执行下一个case分支,遇到break通过对应字节码goto跳转,即此时会执行两个或多个case分支
jdk7引入了switch的新特性,case值可以是String类型,jdk5的新特性switch引入了枚举类型;如果case值是字符串类型,在生成case-offset对时case值为字符串的,然后根据哈希值严格升序;根据给定字符串的哈希值去匹配对应的偏移量,然后调用String的equals方法去验证两个字符串是否相等;这种方式比每个字符串都去调用equals方法来判断效率高
无条件跳转指令
1️⃣goto:接收两个字节的操作数,直接跳转到指定偏移量的字节码,可以向前跳转也可以向后跳转
2️⃣goto_w:偏移量太大超过双字节的带符号整数的范围可以使用goto_w,接收四个字节的操作数,可以表示更大的地址范围,可以向前跳转也可以向后跳转
🔎:while和for循环都是通过无条件跳转指令来实现的,并没有那么多实际代码,while循环就是一个条件判断指令和一个无条件跳转指令搭配;for循环也只是一个比较运算指令、一个条件跳转指令和一个无条件跳转指令组合实现的
for循环内部[包括括号中]定义的i++不可以在for循环外部使用
do{i++}while(i<=100)至少会执行一次代码块中的i++操作
异常处理指令
异常处理过程:
异常对象的生成:异常可以通过用户手动调用throw抛出,出现许多系统自定义好的运行时异常时JVM检测到异常状况会自动抛出
这些运行时异常不会出现在异常表中也不会生成对应的athrow指令
异常的处理:通过try-catch-finally抓抛模型处理,早期JVM处理异常通过字节码指令jsr、ret指令来实现[已废弃],现在使用异常表来进行处理
如果方法通过throws抛出异常,会在字节码文件中的方法表中生成对应方法的Exceptions属性会记录方法可能抛出的所有异常;throw手动抛出通过指令athrow实现
异常表:只要一个方法定义了try-catch或者try-finally[没有catch块会在finally对应字节码执行完毕后执行athrow手动将异常抛给上层调用方法]结构或者throw抛出异常,就会创建异常表,异常表保存了每个异常的处理信息和finally块信息,异常表的信息包括异常捕获范围的字节码偏移量起始位置、结束位置、程序计数器记录的异常处理的字节码偏移地址、被捕获的异常类在常量池中的索引
异常表Exception table在方法表的Code属性中
异常也存在多态,只要是异常表记录的异常类的子实现类就行,也会根据异常表的信息跳转到对应的异常处理字节码指令
发生异常后根据异常表找到对应异常的catch块对应的字节码偏移量地址,将异常对象保存到操作数栈;然后将异常对象存入局部变量表的索引为0处,如果是实例方法会存放在索引为1处;然后执行catch块中的代码比如又将异常对象加载到操作数栈中,调用对应异常对象的实例方法printStackTrace()打印异常堆栈信息;执行完catch语句如果没有finally块直接通过无条件跳转指令goto跳转到return结束方法执行
说的简单点就是根据异常类型去异常表找对应catch的字节码地址直接跳转执行,没有finally直接通过无条件跳转指令跳转返回指令return,有finally就跳转finally对应字节码指令执行后再执行return
一个异常被抛出时,JVM会在当前方法里找一个匹配的异常处理,如果没有找到,当前方法会强制结束并弹栈当前栈帧并将异常重新抛给上层调用的方法栈帧,如果所有栈帧弹出前仍然没有找到合适的异常处理,当前线程将被终止;如果异常在最后一个非守护线程抛出,会导致JVM终止
实例
返回值是hello,按理说执行return前会先执行finally将字符串重新赋值atguigu,但事实并非如此;经过字节码分析,在返回前确实先执行了finally,也确实将局部变量表中原来应该返回的变量修改了,但是在修改前还复制了一份改之前的保存在局部变量表中,在finally块执行结束后直接返回拷贝的原变量,finally块对已经获取返回值的变量的修改不会生效,编译的时候编译器会自己判断return是否发生在finally之前,但是不会在字节码指令上表现出来,字节码仍然是先执行finally然后再执行return,只是编译器已经处理成返回执行finally前被复制的返回值
这里总结一下:try-catch-finally 中的return返回值,采取就近原则,如果在执行finally之前遇到了return,那么在finally中修改之前return返回的变量,将不会生效
xxxxxxxxxxpublic static String func(){ String str = "hello"; try{ return str; } finally{ str="atguigu"; }}注意一旦return在finally后面执行,那么fianlly对原变量的修改仍然会生效
这个例子返回的是atguigu
xxxxxxxxxxpublic static String func(){ String str = "hello"; try{ int i=1; } finally{ str="atguigu"; } return str;}1️⃣athrow:抛出异常指令,Java中的throw语句都是由athrow指令来实现的,正常创建对象的方式创建异常对象,然后调用athrow指令抛出;只要执行了athrow指令当前栈帧会直接被销毁
同步控制指令
同步有同步方法[方法级的同步]和同步代码块[方法内部一段指令序列的同步],两种同步方式都是通过同步监视器[管程]来实现和控制的
方法级的同步是隐式的,不管方法是否加synchronized关键字,方法编译出来的字节码指令都长一个样,也不会使用monitorenter和monitorexit进行同步区控制,方法调用时虚拟机通过方法表结构的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法,如果设置了ACC_SYNCHRONIZED访问标志,执行线程会先持有同步锁然后菜户执行方法,并在方法正常完成或者非正常完成时[异常抛到同步方法外时]释放同步锁,加锁和释放锁都由JVM自动进行控制
方法内指定指令序列的同步:synchronized同步块需要monitorenter和monitorexit两条指令支持,当一个线程进入同步代码块时会使用monitorenter指令请求进入,如果同步对象的监视器计数器为0就会允许线程进入同步代码块;如果同步对象的监视器计数器为1会判断持有监视器的线程是否为进入同步代码块的线程,如果是就进入同步代码块,如果不是则进行等待;线程退出同步块的时候使用monitorexit声明退出,JVM中任何一个对象都有一个监视器与之关联,用来判断对象是否被锁定,监视器被持有后对象处于锁定状态
monitorenter和monitorexit执行时都需要在操作数栈压入同步对象[也称为同步监视器],每个对象的对象头中的运行时元数据中都有一个锁状态标识,最初为0,当有线程执行monitorenter占有同步监视器后锁状态标识就会改成1
1️⃣monitorenter:同步代码块握有同步监视器时会先向局部变量表中保存同步监视器,然后向操作数栈压入同步监视器引用地址,monitorenter执行时会去检查同步监视器的对象头中的锁状态标识,如果符合条件就将增加锁状态计数并且在同步监视器中的owner中记录握有锁的线程,不满足条件就进行等待直到符合条件;一旦当前线程握有同步监视器,线程就会进入同步代码块执行同步代码
2️⃣monitorexit:向操作数栈压入同步监视器的引用地址,monitorexit执行时会将同步监视器对象头中的锁状态标识减去1释放锁退出同步代码块
同步代码块会自动在异常表添加对任何类型的异常处理,异常的处理方法是将异常对象保存到局部变量表,向操作数栈压入监视器的引用地址,调用monitorexit释放锁,向操作数栈压入异常对象,通过athrow指令手动抛出异常,然后执行return指令结束方法的执行;此外如果在出现异常释放锁期间又出现异常了,又会通过异常表的方式再次跳转到捕获所有异常释放锁的字节码指令,异常表中的两个异常共用的一块异常捕获处理字节码指令
面试题:i++和++i的区别[--是一样的道理]
int i=10;i++;的字节码
xxxxxxxxxx0 bipush 102 istore_13 iinc 1 by 16 returnint i=10;++i的字节码
从两者的字节码来看,++i和i++没有任何区别,在循环中使用++i或者++i效果是一样的
注意iinc 1 by 1是将局部变量表中索引为1的变量值加1,不是操作操作数栈中的数据
xxxxxxxxxx0 bipush 102 istore_13 iinc 1 by 16 returnint i=10;int a=i++;
从字节码上来看是将局部变量表中的索引为1的变量值压栈操作数栈,然后将局部变量表中索引为1的变量值加1,然后将操作数栈中的变量值10存入局部变量表索引为2的Slot槽中
宏观上来看就是先将i的值赋值给a,然后变量i再自增1
xxxxxxxxxx0 bipush 102 istore_13 iload_14 iinc 1 by 17 istore_2int i=10;int a=++i;
从字节码上来看是先将局部变量表中索引为1的变量值加1,然后将局部变量表中索引为1的变量值11压栈操作数栈,然后将操作数栈中的变量值11存入局部变量表中索引为2的Slot槽中
宏观上来看就是先变量i自增1,然后在将变量i赋值给a
记住有赋值操作+在前面先自增,归根结底是将变量a的值是在自增前还是自增后压栈操作数栈,而自增操作不需要经过操作数栈,直接更改局部变量表中的值
根据以上规律int i=10;i=i++;最后i的值还是等于10,按直觉看起来好像应该是11,实际上自增在后,压榨在前
xxxxxxxxxx0 bipush 102 istore_13 iinc 1 by 1 4 iload_17 istore_2
简述TLAB[Thread Local Allocation Buffer]
多线程并发访问堆区的共享数据存在线程安全问题,为了避免多个线程同时创建操作同一个资源,需要对线程加锁来保证每个线程操作该资源的原子性从而保证线程并发安全,但是加锁会影响程序执行效率
TLAB是在伊甸园区给每个线程分配一个私有的缓存区,每个线程优先在缓存区内创建对象,缓存区没空间了才在公共的伊甸园区创建对象,以提升系统的吞吐量并避免线程安全问题,这种内存分配方式被称为快速分配策略,所有由OpenJDK衍生出来的JVM都提供了TLAB的设计
默认情况下TLAB空间非常小,只占伊甸园区容量的1%,JVM只是将TLAB作为内存分配的首选,但是并不是所有的对象都能在TLAB中成功分配内存,通过JVM参数-XX:useTLAB可以设置开启TLAB空间[默认情况下是开启的],还可以通过JVM参数-XX:TLABWasteTargetPercent设置TLAB占伊甸园区容量的百分比大小;TLAB的默认大小为(Eden*2*1%)/线程个数
一旦对象在TLAB分配内存空间失败,会直接在伊甸园区再分配一个新的TLAB分配内存,如果伊甸园区内存空间不足无法分配TLAB会尝试进行YGC并继续从伊甸园区分配TLAB给线程分配对象;如果无法申新的TLAB,此时就会直接在堆内存上分配对象,JVM尝试采用加锁的方式来保证多线程下的直接在堆内存分配对象的原子性
注意这里老师没讲清楚,弹幕补充:TLAB是为了解决线程内存分配的问题,两个线程可能争抢一块内存区域。但是TLAB内存的数据还是可以被线程共享的,还是存在线程安全问题,这里只是把TLAB理解成避免多个线程在为对象分配内存时争抢同一块内存区域将原来的加锁分配内存变成了每个线程在自己的TLAB中为对象分配内存,分配好的对象仍然可以被多线程共享且存在线程安全问题;所有线程共享堆内存,对内存使用全局的top分配指针标记下一个可用的内存位置,多个线程尝试分配内存时会更新这个全局指针,为了保证内存分配的线程安全,如果没有TLAB,JVM会在更新全局指针时让多个线程竞争锁,降低内存分配的性能;注意分配TLAB给线程的过程是要加锁的,JVM使用CAS锁来为线程分配TLAB
TLAB存不下新对象重新从伊甸园区为线程分配一块新的TLAB,旧的TLAB的剩余空间不会再继续使用,而且单个TLAB本身空间就比较小只占伊甸园区的1%还要除以线程总数,很容易就会出现剩余空间无法存放新对象的情况,TLAB的内存浪费现象比较严重,这会导致JVM运行过程中始终有一部分内存无法被使用,为此JVM使用最大浪费空间对TLAB进行约束,当TLAB剩余空间存不下新对象且剩余空间小于最大浪费空间,TLAB所属线程会向JVM申请一块新的TLAB区域存储新对象,如果新TLAB仍然存不下,对象会被直接分配到伊甸园区;如果当前TLAB的剩余空间大于最大浪费空间,对象会被直接分配到伊甸园区;默认最大浪费空间的JVM参数TLABRefillWastePraction为64,表示值为TLAB大小的1/64
简述Tomcat类加载机制
简述堆环境变量Classpath的理解,如果一个类不在classpath下为什么会抛出ClassNotFoundException,不改变该类的类路径前提下如何正确加载这个类
调优概述
生产环境可能发生的问题场景
内存溢出
服务器应该分配多少内存
垃圾回收器如何调优
CPU飚高如何处理
应该分配多少个线程
没有日志的情况下如何确定请求是否执行了某一行代码
没有日志的情况下如何实时查看某个方法的入参和返回值
调优的目的
避免发生OOM,发生OOM以后要能解决OOM问题,减少Full GC出现的频率
需要针对应用上线前、项目运营中和线上出现OOM的几个场景下分别对JVM进行有不同侧重的调优
上线前测试发现CPU飚高、请求延迟高、TPS偏低、内存泄漏和内存溢出等
运行期间需要对生产环境进行监控,比如查看分析GC日志、运行日志、异常堆栈、线程快照、堆转储快照等
性能调优的一般步骤
其实就是发现问题、排查问题和解决问题,拽名词可以分别说成性能监控、性能分析、性能调优
性能监控主要监控GC频率、CPU占用、OOM、内存泄漏、死锁,程序响应时间较长
通过性能监控工具收集应用运营性能数据来发现系统存在的问题
性能分析指针对某种问题使用各种工具主动排查问题出现的原因,性能分析一般是在开发和测试环节下进行
导出GC日志,使用GCviewer或者一些线上的工具比如gceasy.io来分析日志信息;
使用jdk提供的命令行工具比如jstack查看堆栈信息、jmap、jinfo等排查分析系统性能;
dump出堆快照,使用内存分析工具比如Eclipse的MAT来分析堆的情况;
使用可视化工具比如阿里的Arthas、或者jdk自带的jconsole、JvisualVM来实时查看JVM的状态
性能调优指为了改善系统吞吐量、响应时间针对性地更改配置参数、代码,主要目的是减少GC频率,Full GC出现的次数,在保证响应时间和吞吐量的前提下尽可能降低内存的使用量;性能调优方案只能针对具体问题具体分析给出,没有统一的解决办法
根据业务场景选择合适的垃圾收集器、配置合适的堆内存
从业务代码层面控制内存的使用行为
高并发场景下服务器的扩缩容和流控手段
合理设置线程池线程的数量
使用缓存中间件、消息队列等中间件来提高程序运行的效率
系统调优指标
响应时间:用户从提交请求到接收到请求的响应之间的间隔时间,一般关注平均响应时间、多数用户的响应时间,一般请求的响应时间为数据在应用系统中的流转时间与垃圾回收阶段的暂停时间的和
[系统中数据的平均处理时间]
| 操作 | 响应时间 |
|---|---|
| 打开一个站点 | 几秒 |
| 查询一条有索引的数据库记录 | 十几毫秒 |
| 机械磁盘一次寻址 | 4ms |
机械磁盘顺序读取1M数据 | 2ms |
从SSD磁盘即固态硬盘读取1M数据 | 0.3ms |
从远程分布式缓存Redis读取一个数据 | 0.5ms |
从内存读取1M数据 | 十几微秒 |
Java本地方法调用 | 几微秒 |
| 局域网网络延迟 | 十毫秒 |
在网络中传输2KB数据 | 1微秒 |
[垃圾收集环节的暂停时间]
像G1这种比较新的垃圾收集器都能通过JVM参数-XX:MaxGCPauseMillis来设置暂停时间的最大值
吞吐量:单位时间内完成响应的请求的数量
垃圾回收中吞吐量指用户线程运行时间占总运行时间的比例,吞吐量可以通过JVM参数-XX:GCTimeRatio来设置用户线程运行时间和垃圾收集线程运行时间的比例
从应用的角度上来看吞吐量主要受并发数和响应时间的影响,并发数低响应速度即使很快吞吐量也不会高,随着并发数的增加响应速度变慢了但是吞吐量会增加,并发数太高导致响应速度太慢吞吐量反而会下降当并发数超过系统瓶颈吞吐量变为0
从JVM调优上主要关注响应时间和吞吐量两个指标
并发数:同一时刻,对服务器产生十几交互的请求数,一般来说并发数为在线人数的5-15%之间,比如1000的在线人数并发量在50-150之间
内存占用:主要关注堆区占用实际内存的大小,一般内存溢出都是堆区发生的,元空间有可能但是机会不大
命令行工具
JDK除了解释运行Java程序,还提供了一系列监控JVM运行情况的工具,这些工具都在JAVA_HOME/bin目录下,工具源码在JAVA_HOME/lib/tools的jar包中,都是打包后的字节码文件;源码可以在https://hg.openjdk.java.net/jdk/jdk11/file/1ddf9a99e4ad/src/jdk.jcmd/share/classes/sun/tools查看,只有涉及到修改命令行的功能才需要看源码,一般只需要掌握命令行工具的使用;这些命令在windows和linux中是一样的用法
命令行工具的局限性
无法获取如方法间的调用关系、方法的调用次数、方法的调用时间等方法级别的分析数据,这些数据对定位系统性能瓶颈至关重要
需要用户登录JVM进程所在宿主机,数据的结果展示和分析结果通过终端输出,不够直观
JPS[Java Process Status]:查看指定操作系统内所有HotSpot虚拟机进程状态
JPS:查看当前操作系统上运行的所有JVM进程和对应进程号,JPS本身也是一个JVM进程,IDEA也是JVM进程,但是进程名不显示
jps [options] [hostid]:options是一系列可选配置参数,可选择的参数有
jps -q:仅显示JVM进程号,不显示主类名称;
jps -l:显示进程号和进程主类名称,而且主类名称会显示为全类名;如果执行的是jar包则输出jar包的绝对路径
jps -m:除了进程号和主类名称,额外展示虚拟机进程启动时用户手动给主方法传递的参数值
jps -v:列出虚拟机启动时配置的JVM参数,还会显示用户没有手动指明的部分JVM参数
jps -l -m:多个配置参数可以组合使用,显示进程号、主类全类名以及主方法的形参参数值,也可以写成jps -lm,但是有些参数组合之间是冲突的,比如-q和-l,可能会报错非法参数;-lmv是可以任意组合使用的
hostid用于查看远程主机上的Java进程,这种情况下需要安装jstatd配合使用,这种使用jstatd远程访问Java进程的方式很容易受到IP地址欺诈攻击,建议只在本地使用jstat和jps工具
如果Java进程关闭了默认开启的JVM参数-XX:-UsePerfData,此时jps命令无法探知该Java进程
jstat[JVM Statistics Monitoring Tool]:监控JVM各种运行状态信息的命令行工具,可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据,没有GUI图形界面只有纯文本控制台的服务器环境下运行期定位虚拟机性能的首选工具,一般用于检测垃圾回收问题和内存泄漏问题,官方提供的jstat文档https://docs.oracle.com/javase/8/docs/technotes/tools/unix/jstat.html
语法格式:jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]
option可选配置
| 配置项 | 配置含义 |
|---|---|
-class | 显示类加载器相关信息 显示类的装载、卸载数量和两者对应字节数 类装载消耗时间等类加载器信息 实例: jstat -class 9000 |
-compiler | 显示JIT编译器编译过的方法数量、耗时信息实例: jstat -complier 9000 |
-printcompilation | 显示被JIT编译器编译过的具体方法实例: jstat -printcomplication 9000 |
-gc | 显示与GC相关的堆信息Eden、两个Survivor、老年代、永久代的总容量、已使用空间以及GC的合计时间SOC:幸存者0区总容量S1C:幸存者1区总容量S0U:幸存者0区已经使用的容量S1U:幸存者1区已经使用的容量EC:伊甸园区总容量EU:伊甸园区已经使用的容量OC:老年代总容量OU:老年代已经使用的容量MC:方法区总容量MU:方法区已经使用的容量OGSC:压缩类的总容量GCSU:压缩类已经使用的容量YGC:YGC发生的次数YGCT:YGC耗费的时间FGC:FGC发生的次数FGCT:FGC耗费的时间GCT:GC耗费的总时间,就是YGCT+FGCT |
-gccapacity | 显示内容基本与-gc相同,主要侧重Java堆的最大最小空间 |
-gcutil | 显示内容基本与-gc相同,主要侧重堆各区域已使用空间占最大空间的百分比 |
-gccause | 显示内容与-gcutil相同,额外输出一个字段表示导致最后一次或者当前正在发生的GC原因 |
-gcnew | 显示新生代的GC信息 |
-gcnewcapacity | 显示内容与-gcnewcapacity基本相同,主要侧重区域的最大最小空间 |
-gcold | 显示老年代的GC信息 |
-gcoldcapacity | 显示内容与-gcold基本相同,主要侧重区域的最大最小空间 |
vmid:虚拟机的进程id
interval:指定jstat指令的查询统计结果打印输出间隔,单位是毫秒,默认情况下只会打印一次
只指定interval不指定count的情况下会持续打印统计结果直到被统计的JVM进程结束,直接Ctrl+C也能终止掉命令行的执行
示例:jstat -class 9000 1000
count:指定jstat指令的查询统计结果的打印输出总次数,该参数一般要和interval参数搭配才能使用
示例:jstat -class 9000 1000 10
-t:在统计结果中添加一个字段显示被监控的JVM进程从启动到当前时刻经历了多长的时间,单位是秒
示例:jstat -class -t 9000 1000 10
-h:每输出多少次统计结果就额外打印一次表头,因为统计结果以动态表结构的形式给出,防止打印次数太多导致查询每个字段的含义比较麻烦,可以通过该参数配置间隔多少次输出打印一次表头信息
示例:jstat -class -t -h3 9000 1000 10表示每隔3次统计数据打印就额外打印一次表头
应用举例
生产中,一般可以通过jstat拉取GC数据通过计算一段时间段内GC的时间占总时间的比例来评估系统的性能,如果GC时间占运行时间的比例超过20%,说明堆的压力比较大,如果比例超过90%,说明堆里几乎没有什么可用空间,随时都可能抛出OOM
此外还可以根据统计学来评估系统性能,比如从一段时间内获取统计数据中的OU即老年代内存大小的最小值,如果发现程序运行过程中,这个内存值一直在增长,说明GC对老年代的回收不彻底,有些数据一直无法被回收且这样的数据还一直在产生,随着时间推移肯定会出现OOM
jinfo[Configuration Info for Java]:查看和修改JVM的配置参数信息,jps只会显示全部用户指定的JVM参数和少部分JVM自带的参数,想查找任意的JVM参数使用jinfo比较方便,去官方文档找比较麻烦;jinfo修改参数值会立即生效,但是不是所有的参数值都支持动态修改,只有被标记为manageable的参数值才可以被修改,能被jinfo修改的参数非常有限,只有16个参数可以通过jinfo进行修改,但是JVM参数多达600多个,注意通过java -XX:+PrintFlagsFinal打印出来有732个
jinfo -sysprops PID:用于查看指定Java进程的系统属性,这些系统属性可以通过System.getProperties()获取
jinfo -flags PID:会显示曾经被覆值过的JVM参数,会显示非默认参数和命令行参数,命令行参数就是用户指定的JVM参数,非默认参数包含用户指定的命令行参数和JVM根据系统环境自己调整过的非默认参数
jinfo -flag 指定参数名 PID:查看指定参数名的参数值,比如jinfo -flag UseParallelGC 3540会返回-XX:+UseParallelGC;jinfo -flag MaxHeapSize 3540会返回-XX:MaxHeapSize=104857600
java -XX:+PrintFlagsFinal -version | grep manageable可以查看所有被标记为manageable的参数,windows上对应的命令为java -XX:+PrintFlagsFinal -version | findstr manageable
这些可更改的参数分为布尔类型和值类型,分别对应不同的修改指令结构
xxxxxxxxxxC:\Windows\system32>java -XX:+PrintFlagsFinal -version | findstr manageable intx CMSAbortablePrecleanWaitMillis = 100 {manageable} intx CMSTriggerInterval = -1 {manageable} intx CMSWaitDuration = 2000 {manageable} bool HeapDumpAfterFullGC = false {manageable} bool HeapDumpBeforeFullGC = false {manageable} bool HeapDumpOnOutOfMemoryError = false {manageable} ccstr HeapDumpPath = {manageable} uintx MaxHeapFreeRatio = 100 {manageable} uintx MinHeapFreeRatio = 0 {manageable} bool PrintClassHistogram = false {manageable} bool PrintClassHistogramAfterFullGC = false {manageable} bool PrintClassHistogramBeforeFullGC = false {manageable} bool PrintConcurrentLocks = false {manageable} bool PrintGC = false {manageable} bool PrintGCDateStamps = false {manageable} bool PrintGCDetails = false {manageable} bool PrintGCID = false {manageable} bool PrintGCTimeStamps = false {manageable} java version "1.8.0_101" Java(TM) SE Runtime Environment (build 1.8.0_101-b13) Java HotSpot(TM) 64-Bit Server VM (build 25.101-b13, mixed mode)jinfo -flag +PrintGCDetails 3540将布尔类型参数值修改为true,比如该实例就是将参数PrintGCDetails从-PrintGCDetails改成+PrintGCDeatils,表示启用打印GC详细信息
jinfo -flag MaxHeapFreeRatio=90 3540将值类型的参数MaxHeapFreeRatio的值修改为90
除了jinfo命令,JDK还提供了java -XX:+PrintFlagsInitial命令打印所有JVM参数的默认值,java -XX:+PrintFlagsFinal打印所有JVM参数的实际值,java -XX:+PrintCommandLineFlags打印被用户设置过或者被JVM自动设置过的JVM参数
java -XX:+PrintFlagsFinal指令获取的结果修改过的值在具体值前面会显示为:=,没被修改过的值会显示为=
jmap[JVM Memory Map]:导出内存映像文件,该命令的作用一方面是获取堆转储快照dump文件,获取离当前最近一个安全点时刻堆中对象占用内存大小的记录,还可以指定不同的参数获取目标Java进程的内存信息包括Java堆中各个区域的使用情况,堆中对象的统计信息和类加载信息等,甚至还可以按照对象的大小进行排序,是一个二进制文件因此不能直接通过文本软件打开,称为dump文件的原因是命令参数中还有dump,dump文件一般发生OOM或者内存泄漏时用来分析问题的源头;堆转储文件会保存所有的对象、类、GC Roots、调用栈即Java中的虚拟机栈信息
基本语法:
jmap [option] <pid>
jmap [option] <executable <core>>
jmap [option] [server_id@] <remote server IP or hostname>远程访问Java进程
常见option参数值
| 配置项 | 配置含义 |
|---|---|
-dump | 生成堆转储快照文件-dump:live只保存堆中的存活对象 |
-heap | 输出当前命令执行时刻的堆空间详细信息比如GC的使用信息、堆JVM配置参数信息以及内存的实际使用情况等jmap只能展示某个时间点的堆情况,jstat能展示一定时间间隔上的堆情况,GUI则更高级一些以图形化界面的形式展现堆情况 |
-histo | 输出堆中对象的统计信息、包括类、类实例数量和当前类实例占用的合计容量-dump:live只保存堆中的存活对象 |
-permstat | 输出永久代的内存信息也就是加载类的信息 仅 linux/solaris平台有效 |
-finalizerinfo | 输出在F-Queue队列中等待被Finalizer线程执行finalize方法的对象仅 linux/solaris平台有效 |
-F | 如果使用-dump参数生成dump文件时没有任何响应,添加了该参数会强制执行生成dump文件仅 linux/solaris平台有效 |
导出内存映像文件
生成Dump文件前会触发一次Full GC,dump文件中保存的都是Full GC后留下的对象信息
dump文件生成比较耗时,尤其是大内存镜像生成dump文件时会更耗时
手动导出dump文件:导出堆中全部对象的语法为jmap -dump:format=b.file=<filename.hprof> <pid>[示例:jmap -dump:format-b,file=d:\1.hprof 3450],导出堆中存活对象的语法为jmap -dump:live,format=b,file=d:\1.hrefo <pid>[示例:jmap -dump:live,format-b,file=d:\1.hprof 3450],dump文件是一个二进制流文件,需要使用专门的像Profile、jconsole、jvisualvm、MAT这种软件打开,普通的文本软件是无法识别的,也可以使用命令行工具
命令中format-b的作用是让生成的dump文件的格式要和hprof保持一致
堆中的对象越多dump文件就越大,生产环境中一般dump文件都会多达几百兆,从机器上下弄下来再一通分析完可能距离出现OOM大半天就过去了,此时可以选择只下载保存存活对象的dump文件,这样速度更快,因为OOM一般发生也是由于GC回收不走的对象导致
出现OOM时自动导出dump文件:因此系统出现OOM时会自动退出系统,此时瞬时信息都随着程序的终止而消失,因此需要在发生OOM的时候自动导出一份dump文件方便故障排查
通过配置JVM参数-XX:+HeapDumpOnOutOfMemoryError在程序发生OOM时导出应用程序的当前堆快照,通过配置JVM参数-XX:HeapDumpPath可以指定堆快照的保存位置[配置示例:-Xmx100m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\m.hprof]
通过配置JVM参数-XX:+HeapDumpBeforeFullGC可以配置当JVM发生Full GC前自动导出一份dump文件
因为JVM总是到安全点才会停下来导出dump文件以保证导出dump文件的过程不会被应用线程干扰,这会导致执行指令时刻到安全点之间的对象如果在这期间被销毁了,会导致配置了:live指令的快照结果和执行指令时刻的实际情况出现偏差
此外如果某个线程长时间无法跑到安全点,jmap只能一直等待下去;但是像jstat这样的指令不要求安全点
jhat[JVM Heap Analysis Tool]:JDK提供的堆分析工具,与jmap命令搭配使用用于分析jmap生成的dump文件,jhat内置了一个微型的HTTP/HTML服务器可以查看jhat对dump文件的分析结果
jhat在JDK9、JDK10中已经被移除,官方建议使用JVisualVM来代替
使用命令jhat dump文件带后缀名地址分析dump文件,通过结果给出的端口号通过浏览器可以访问到分析结果,一次只支持分析一个dump文件,还可以通过OQL语句查询符合条件的对象;不会直接在生产环境使用该指令分析dump文件
jhat [option] [dumpfile],option可选择的配置参数包括
-stack false|true:关闭或者打开对象分配调用栈的追踪
-ref false|true:关闭或者打开对象引用的追踪
-port port-number:设置jhat的服务器的访问端口号,默认端口号是7000
-version:启动后仅打印jhat的版本就退出
jstack:打印JVM中所有线程的虚拟机栈快照[信息很少,并不是虚拟机栈的所有数据都打印,主要是线程和线程状态和一些简单的分析],开发者分析线程的执行状态,线程在运行期间可能由于线程死锁、死循环、调用sleep、wait方法、请求外部资源导致长时间等待导致线程出现不正常长时间停顿的问题,我们想整体上把握哪些线程出现了长时间等待的情况可以使用jstack指令来获取各个线程的运行快照数据来分析得出
官方文档:https://docs.oracle.com/en/java/javase/11/tools/jstack.html
线程快照中会显示当前线程的状态,分别为Deadlock[死锁]、Waiting on condition[等待资源]、Waiting on monitor entry[等待获取监视器]、Blocked[阻塞]、Runnable[执行中]、Suspended[暂停]、Object.wait()或TIMED_WAITING[线程等待中]、Parked[停止]
基本语法:
jstack [option] <pid>,option可选配置
-F:指令不能被正常响应时强制输出线程堆栈信息
-l:除了堆栈信息额外显示锁的附加信息
-m:如果调用了本地方法可以显示本地方法栈信息
-h:jstack的使用指南
jstack <pid>:打印当前进程对应的所有线程和相应线程状态,最后还会列举进程中线程存在的问题
Java中Thread.getAllStackTraces()获取Map<Thread,StackTraceElement[]>也能通过Java程序获取进程中每个线程的状态,信息没有jstack归纳的那么智能
jcmd:多功能工具,可以实现除了jstat外的所有命令行工具的功能,比如导出堆栈、查看内存使用、查看Java进程、导出线程信息、GC的执行时间、JVM的运行时间等;jcmd拥有jmap的大部分功能,官方推荐使用jcmd替代jmap
官方文档:https://docs.oracle.com/en/java/javase/11/tools/jcmd.html
jcmd:效果和jps -l的效果相同,额外显示主方法的形参值
jcmd -l:效果和jps -m的效果相同
jcmd <pid> help:查看指定进程的jcmd <pid> 具体命令支持的所有命令,这些jcmd <pid> 具体命令就能替换掉此前大部分命令的功能
jcmd <pid> 具体命令:比如jcmd 8320 Thread.print,和jstack命令的效果相同;jcmd 8320 GC.class_histogram,和jmap -histo命令的效果相同打印堆中类和类实例数量,内存占用信息;jcmd 8320 GC.heap_dump d:\1.hprof效果和jmap -dump:format-b,file=d:\1.hprof 8320效果是一样的;jcmd 8320 VM.iptime展示指定进程启动到当前时刻的持续时间;jcmd 8320 VM.system_properties打印可以通过System.getProperties()获取的系统属性信息;jcmd 8320 VM.flags:打印非默认值的JVM参数
jstatd:一些命令行监控工具比如jps、jstat支持对远程计算机的监控,启用远程监控需要配合使用jstatd工具,jstatd是一个RMI服务端程序,作为代理服务器建立本地计算机与远程监控工具之间的通信,jstatd将本机的Java应用程序信息传递到远程计算机上供监控工具分析
GUI工具
JDK的bin目录下自带了jconsole、jvisualvm、jmc三个图形化综合诊断工具
jconsole:用于查看JVM应用的运行情况、监控堆区、方法区的使用情况和类加载器情况,功能简陋,入门级别的GUI系统监控工具
visualVM:jdk内置了jvisualVM,用户也可以自己下载visualVM,相较于jconsole功能更强大
JMC[Java Mission Control]:JMC是JRockit VM提供的工具,oracle收购BEA后将JMC整合到HotSpot中。JMC中内置了Java Flight Recorder,能够以极低的性能开销搜集JVM的性能数据
第三方的GUI工具
MAT[Memory Analyzer Tool]:由Eclipse开发的堆内存分析工具,可以作为一个插件在Eclipse上使用,也可以独立安装使用。一般用于帮助开发者查找内存泄漏和减少内存开销
JProfiler:相较于visualVM功能强大的商业软件
Arthas:国内比较流行的阿里开源的Java诊断工具
Btrace:Java运行时追踪工具,可以在不停机的情况下追踪指定方法、构造方法的调用和系统内存信息
康师傅推荐必须掌握visualVM,在此基础上掌握Arthas、然后是JProfiler,在dump文件分析方面掌握MAT
JConsole:
介绍:从JDK5开始自带的java监控和管理控制台,用于对JVM中内存、线程和类的监控,是一款基于JMX[java management extensions]的GUI性能监控工具
官方文档:https://docs.oracle.com/javase/7/docs/technotes/guides/management/jconsole.html
JVM进程连接方式
本地:Jconsole使用文件系统的授权通过RMI连接器连接到正在本地系统运行的JVM
此外还要求操作系统运行JVM进程的用户和运行Jconsole的用户是同一个
远程:使用service:jmx:rmi:///jndi/rmi://hostName:portNum/jmxrmi通过RMI连接器连接到一个JMX代理,还需要在环境变量中设置mx.remote.credentials指定用户名和密码为远程JVM进程授权来实现JConsole和远程JVM进程之间建立连接
jconsole的监控面板
概述:
以折线图的方式展示堆内存使用量、加载线程数、加载类的个数、CPU占用率随时间的变化
内存:
展示堆[可选老年代、伊甸园区、幸存者区]和非堆内存[可选元空间、代码缓存、压缩类]占用量随时间的变化折线表
可以点击执行GC按钮让JVM强制执行一次GC
点击堆dump可以生成dump文件
线程:
显示加载的总线程数,每个线程的详细信息,还可以通过检测死锁按钮来判断系统是否发生了死锁
类:
显示加载类的总数量
VM概要:
JVM进程运行、配置情况概述
Visual VM
介绍:Visual VM是一款功能强大的故障诊断和性能监控的可视化工具,几乎将所有的命令行工具都整合到Visual VM中,可以显示指定操作系统中的全部虚拟机进程,进程的系统参数配置、环境信息,监视CPU、GC、堆、方法区、线程的信息,Visual VM可以完美地取代JConsole。是JDK1.6u7开始引入的,JDK8后期版本及更高的版本需要从官网下载独立版本,官网:https://visualvm.github.io/index.html,visualVM是独立的软件,被JDK整合到bin目录下作为标准工具组件,因为这些组件都以j开头,因此JDK整合visual VM就被起名为jvisualVM体现为JDK的一部分,一般独立安装的visual VM就叫visual VM
Visual VM支持插件扩展,插件安装可以通过以.nbm作为后缀的离线插件文件,插件对话框中已下载页面添加已下载的插件或者在线安装插件,一般建议安装内存空间可视化柱状图插件VisualGC;
github上visualvm的插件中心:https://visualvm.github.io/pluginscenters.html,点进不同的visualVM版本后可以查看当前版本可以安装的插件列表,开发者可以在这里下载插件文件到本地
也可以在visual VM的插件面板搜素具体插件直接安装,但是需要提前对插件中心进行配置,否则安装插件可能会失败,配置过程参考谷粒商城中对jvisualVM的配置文档
IDEA中也可以安装VisualVM Launcher插件,这个插件的作用只是启动程序的同时也自动启动jvisualVM
一个Visual VM可以同时监控多个Java进程
Visual VM的功能比较强大,最起码需要掌握的可视化工具就是viusalVM
连接方式:
本地连接:visualVM界面本地可连接的JVM进程,点击就能自动进行连接
远程连接步骤:
在远程服务器上启用目标Java应用程序时添加JMX远程监控支持
xxxxxxxxxxjava -Dcom.sun.management.jmxremote \ -Dcom.sun.management.jmxremote.port=12345 \ -Dcom.sun.management.jmxremote.authenticate=false \ -Dcom.sun.management.jmxremote.ssl=false \ -Djava.rmi.server.hostname=远程服务器IP地址 \ -jar your-application.jar-Dcom.sun.management.jmxremote.port=12345:指定JMX服务监听的端口
-Dcom.sun.management.jmxremote.authenticate=false:禁用身份验证[生产环境中建议启用]
-Dcom.sun.management.jmxremote.ssl=false:禁用SSL[生产环境中建议启用]
-Djava.rmi.server.hostname:指定远程服务器的 IP 地址或主机名
配置远程服务器的防火墙策略允许VisualVM所在机器访问远程服务器的JMX端口
在 Linux 上可以使用以下命令:
xxxxxxxxxxfirewall-cmd --zone=public --add-port=12345/tcp --permanentfirewall-cmd --reload本地启动VisualVM[独立的VisualVM需要保证和JDK版本的兼容性]
在VisualVM主界面点击文件--添加远程主机,输入远程服务器的IP地址添加远程主机
右键点击刚刚添加的远程主机,选择添加JMX连接,输入远程主机的JMX端口添加JMX连接
连接成功并监控Java进程的CPU使用率、内存使用情况、线程状态等运行数据
生产环境建议启用身份验证和SSL增强安全性[可选],可以通过以下参数启用:
jmxremote.password.file:包含用户名和密码的文件。
jmxremote.access.file:定义用户权限的文件。
xxxxxxxxxx-Dcom.sun.management.jmxremote.authenticate=true \-Dcom.sun.management.jmxremote.password.file=/path/to/jmxremote.password \-Dcom.sun.management.jmxremote.access.file=/path/to/jmxremote.access \-Dcom.sun.management.jmxremote.ssl=true基础功能
生成读取堆转储内存快照文件
进程列表中指定进程鼠标右键--堆Dump生成dump文件
监视/内存面板点击堆dump生成dump文件
该dump文件需要另存为才能保存下来,否则Visual VM一关闭就会丢失
文件--装入,选择堆Dump,选择目标dump文件就能展示dump文件中的信息
dump文件分析面板可以点击与另一个dump文件进行比较,统计两个指定dump文件之间的差异信息
查看JVM参数和系统属性
查看指定主机中的所有Java进程
生成读取线程快照
线程dump文件和堆dump文件一样有两种生成方式,保存需要另存为,内容和jstack命令生成的内容是相同的
也可以读取线程dump文件到visualVM中
实时监控CPU、GC、堆、方法区、线程的信息
JMX代理连接、远程Java进程监控
CPU、内存抽样
在抽样器面板可以对CPU或者内存进行抽样,对CPU抽样中的CPU样例面板会将占用CPU时间比较长的方法以列表的形式展示出来,线程CPU时间会将每个线程占用CPU时间的多少展示出来并根据占用时间对线程进行排序
内存抽样的堆柱状图会实时展示类的实例个数和占用内存的大小,点击对应的类会显示所有的类实例,与另一个dump文件比较会显示两个dump文件类实例的统计数据差异
Eclipse MAT
介绍:Java堆内存分析工具,查找分析内存泄漏和内存消耗情况。MAT的主要功能就是分析dump文件,不像visualVM一样除了分析dump文件还有比较强的实时监控功能,但是MAT对dump文件的分析功能做的更好一些;MAT是Eclipse的一个插件,只是该插件可以单独下载使用,可以在官网https://www.eclipse.org/mat/downloads.php下载,解压点击可执行文件就能运行,使用MAT打开堆转储内存映像hprof文件就可以看到以下内存信息
所有对象实例、成员变量、存储在虚拟机栈中的基本数据类型值和堆中对象的引用值即对象信息
所有类加载器、类名称、父类、静态变量等类信息
GCRoot到所有可达对象的引用路径
线程的调用栈和线程的局部变量等线程信息
MAT认为的内存泄露只要生命周期太长,比如一个局部变量出了作用范围但仍然被长生命周期对象引用,MAT也会认为这是一个内存泄漏点;像静态变量强引用一个对象,因为静态变量的生命周期和类的生命周期一致,静态变量强引用的对象就可能因为一直无法被回收导致内存泄漏
MAT只能处理主流厂家如SUN、HP、SAP的HPROF二进制堆转储文件,同时也能解析IBM的PHD堆存储文件
MAT最实用的功能是生成内存泄漏报表,像jhat或者其他的工具都只能展示原始的信息,分析结构展示的不够直观,MAT提供的内存泄漏报表能帮助开发者方便地定位问题和分析问题;MAT无法做到一键展示系统所有内存问题的程度,很多内存问题还是需要开发者根据MAT展示的信息通过经验和直觉具体问题具体分析来进行判断
实际生产中一般都是使用MAT对dump文件进行分析,dump文件可以通过jmap命令生成、可以通过JVM参数配置在FullGC或者发生OOM以前自动生成、还可以在visualVM中导出、MAT本身点击File--Acquire Heap Dump也能从弹窗的活跃Java进程中导出堆快照
MAT通过点击File--Open Heap Dump可以打开一个dump文件,使用MAT打开一个dump文件会生成很多不同格式的文件,弹窗选择Leak Suspects Report点击finish还能生成一个zip格式的压缩包
导入dump文件会弹窗并提供三个选项
Leak Suspects Report是生成泄漏疑点报告,会自动检查堆快照检查哪些是内存泄漏疑点,报告哪些对象仍然存活且没有被垃圾收集的原因,这也是默认选项
Component Report是组件报告,自动分析像重复字符串、空集合、终结器、弱引用等一系列可能发生内存问题的被怀疑对象
Re-open previously run reports是打开之前已经存在的泄漏疑点报告或者组件报告,这个报告保存在hprof相同目录的压缩文件中
使用MAT分析dump文件
Overview面板:内存情况概述
Details显示堆空间大小、加载类的个数、对象实例个数、类加载器个数,不可达对象直方图
Biggest Object by Retained Size显示可达对象中最大对象的内存占用情况,将光标悬停会在左侧边栏展示对象的详细信息
Actions显示类的直方图、最大对象的支配树[显示当前对象引用哪些对象以及被哪些对象引用]、Top Consumers根据类和包显示内存占用最大的类和实例数据、Duplicate Classes检测被不同类加载器加载同一个字节码文件生成的不同类
Reports显示各种报告:包含内存泄漏疑点报告[会列举内存泄漏的怀疑点]、
histogram[直方图]:
对应jmap的histo命令参数、visualVM中也有,列举每种类的实例数、浅堆占用大小、深堆占用大小
选中一个类会显示类的包、class实例位置、父类、加载使用的类加载器、有没有GC Root
工具栏有一个下拉列表可以选择将直方图中的类数据按照类、父类、类加载器、包进行分组,默认是按照类进行分组,还可以在列表顶部写正则表达式来检索目标类
选中一个类右键选择Merge Shortest Paths to GC Roots并选择exclude all phantom/weak/soft etc.references排除所有的弱引用、软引用、虚引用,展示当前类中有哪些实例在哪些根节点中被强引用
直方图工具栏最后有一个Compare按钮,可以选择另一个dump文件比较两个dump文件类统计数据直方图之间的差异,差异数据可以选择根据类实例数量等维度进行排序,观察一段时间内导致内存增长最快的类和类实例
支配树[Dominator Tree]:
概念:在对象引用图中,所有指向对象B的完整路径都经过对象A,则认为对象A支配对象B,如果对象A是距离对象B最近的一个支配对象,我们就认为对象A是对象B的直接支配者
判断一个对象B的直接支配者只需要判断要想访问对象B最近只能通过哪个对象
支配树里面的是对象的支配树关系
支配树的性质:
对象A的子树,即所有被对象A支配的对象集合加上对象A本身,就是对象A的保留集
如果对象A支配对象B,那么对象A的直接支配者也支配对象B
支配树的边和对象引用图的边不直接对应
支配树的意义
回收一个对象可以将其支配树下的子树全部回收掉,因为这些子树上的对象只能通过被回收的对象访问
浅堆和深堆:
浅堆[Shallow Heap]:指单个对象实例本身占用的内存大小,属性中基本数据类型计算属性值本身占用大小、引用数据类型计算引用地址的大小而非引用地址指向的实际对象实例的大小;一个类的所有实例的浅堆大小占用只可能小于等于深堆大小占用
32位操作系统中一个对象引用占4个字节,一个int类型占4个字节,一个long类型占8个字节,每个对象头占用8个字节,最后如果对象内存不够8字节的整数倍还会向8字节对齐
比如JDK中的String,有两个int类型的属性hash32和hash,一起占8个字节;一个引用数据类型的属性value占4个字节;对象头占8个字节;一个String对象占20个字节,但是要补齐8字节的倍数,因此一个String对象占用24个字节,也就是String对象的浅堆大小就是24个字节
数组对象的浅堆大小为对象头8个字节加数组长度四个字节加数组长度个元素长度字节,比如Object[22]的长度为8+4+22*4=100个字节。而且是数组长度不是元素个数,ArrayList的初始长度为10,扩容为原来的1.5倍,因此扩容后的数组长度为15,但是元素个数必然少于15,但是数组对象长度还是按照15个元素来计算
深堆[Retained Heap]:
保留集[Retained Set]:对象A的保留集指对象A被垃圾收集后,可以被连带释放的所有对象的集合再加上对象A本身,即对象A的保留集指仅被对象A直接或者间接引用的对象以及对象A本身
深堆:对象的保留集中所有对象的浅堆大小之和,一个对象的深堆大小即一个对象被回收后可以真正被释放的内存空间
对象的大小:对象的实际大小定义为一个对象能触及的所有对象的浅堆大小之和,因为包含了不仅仅只被当前对象引用的对象,因此对象的大小大于等于深堆大小,但是这个概念和垃圾回收无关
OQL查询语句:
在jhat、visualVM、MAT中通过OQL语句过滤掉大部分无用信息检索出目标数据,MAT可以通过首页第四个按钮进入OQL面板,OQL面板点击F1可以展示帮助侧边栏;OQL语句不能加分号,通过快捷键F5执行,如果有多条OQL语句需要选中语句后再通过快捷键F5执行
Select子句
SELECT * FROM java.util.Vector v:*表示以地址引用的形式显示当前JVM进程中指定类java.util.Vector的所有实例
SELECT objects s.value FROM java.lang.String s:onjects表示以对象的形式显示当前JVM进程中指定类java.lang.String的所有实例
SELECT AS RETAINED SET * FROM java.lang.String:AS RETAINED SET表示显示当前JVM进程中指定类java.lang.String的所有实例的保留集
SELECT DISTINCT OBJECTS classof(s) FROM java.lang.String s:DISTINCT用于在查询结果集中去除重复的对象
SELECT v.elementData FROM java.util.ArrayList v:v.elementData只会展示每个对象最基本的属性,包括对象所属类型、对象的内存引用地址、对象如果是数组会显示数组长度,但是不会有下拉列表显示对象的详细信息;可以使用SELECT objects v.elementData FROM java.util.ArrayList v以对象的形式展示每条记录并且显示对象的浅堆深堆大小
FROM子句:用于指定查询范围,可以指定范围为指令类、正则表达式或者对象地址
SELECT * FROM java.lang.String s:结果集中显示的对象地址引用的类型必须为java.lang.String
SELECT * FROM "com\.earl\..*":结果集中显示的对象地址引用的类型必须在包com.earl下
SELECT * FROM 0x37a0b4d:结果集中显示的对象地址引用的类型必须是内存地址0x37a0b4d对应的class对象对应的类,这个地址可以通过在MAT中选中class对象对应类名在侧边栏显示的额外信息中获取
因为一个类比如Student可能被多个类加载器加载形成多个class对象,但是我们使用一个内存地址只可能限定一个class对象,这样能限制结果所处范围只对应一个class对象
WHERE子句:指定OQL语句的查询条件,查询结果只包含满足WHERE子句指定条件的对象
SELECT * FROM char[] s WHERE s.@length>10:查询返回长度大于10的char数组
SELECT * FROM java.lang.String s WHERE toString(s) Like ".*java.*":查询字符串的toString方法返回值中含有子串java的所有字符串,Like操作符的操作数为正则表达式,匹配满足正则表达式的字符串
SELECT * FROM java.lang.String s where s.value != null:查询字符串的value属性不为null的字符串对象
SELECT * FROM java.util.Vector v WHERE v.elementData.@length > 15 AND v.@retainedHeapSize>1000:返回数组长度大于15且深堆大小大于1000字节的所有Vector类型的对象,WHERE字节支持多个条件的AND且、OR或逻辑组合运算
内置对象与方法:OQL语句可以访问堆内对象或者堆内代理对象的属性、格式为[<alias>.]<field>.<field>.<field>,其中alias为对象名称
SELECT toString(f.path.value) FROM java.io.File f:查询所有File实例的path属性的value属性
SELECT s.toString(),s.@objectId,s.@objectAddress FROM java.lang.String s:查询所有String实例的字符串内容、objectId和objectAddress
SELECT v.elementData.@length FROM java.util.Vector v:查询所有Vector实例的内部数组长度
SELECT * FROM INSTANCEOF java.util.Vector:查询所有Vector和其子类实例对象
Thread OverView[线程概述]:查看当前Java进程中所有的线程情况,每个虚拟机栈中栈帧的局部变量情况
从工具栏顶部第五个小齿轮按钮可以直接点进线程概述面板
点开每个线程会显示线程调用的方法和每个方法中的局部变量包括局部变量中引用的变量,局部变量有前缀<local>标识,内存泄露分析报告中可能指出指定线程中的局部变量被怀疑是泄露点并指出对象占用内存在现有对象中占用内存的比例,并指出对象内存占用的具体原因比如哪个属性内存占用太大
每个局部变量右键list objects--with outgoing references会显示当前局部变量引用了哪些对象即出引用,右键list objects--with incoming references会显示当前对象被哪些对象引用即入引用,这些对象还可以按照上述流程查看哪些对象被当前对象引用或者哪些对象引用了当前对象
线程概述里面的局部变量的关系是对象引用图
报告:
堆dump报告:堆空间大小、加载类的个数、对象实例个数、类加载器个数,GCRoots根节点个数、dump文件格式、dump文件生成时间、系统属性参数列表、线程概述、类的直方图
JProfile:
介绍:JProfiler由ej-technologies公司开发的Java性能诊断工具,可以单独使用,也可以作为IDEA中的一款插件使用,官方下载地址https://www.ej-technologies.com/products/jprofiler/overview.html;Jprofiler的功能要比MAT的功能强大的多,MAT主要用于分析堆dump文件,而且比visualVM还强大,但是收费
特点:
提供了常见的监控配置模版,使用简单、功能强大,支持对在线JVM进程的分析也支持对离线dump文件的分析,支持本地JVM进程也支持远程JVM进程
对被分析应用的影响相较于其他软件更小,因为性能监控工具需要获取应用的数据肯定会对被监控系统性能造成影响
对CPU、Thread、Memory的分析功能尤其强大
支持对jdbc、noSql、jsp、servlet、socket的性能监控分析
跨平台,支持多种操作系统的安装版本[windows、Mac、Linux、FreeBSD、Solaris、AIX、HP-UX],可以在https://www.ej-technologies.com/download/jprofiler/version_100选择不同主楼操作系统安装版本进行下载,而且在主流的IDE中能下载相应的插件
功能:
分析提高被调用方法的性能
分析堆中的对象、引用链以及与GC Roots的关系来排查内存泄漏问题,优化内存使用
提供多种针对线程和锁的分析视图帮助发现多线程问题
提供高级子系统对JDBC调用、执行比较慢的SQL语句等环节进行集成分析
数据采集方式
instrumentation重构模式:
JProfiler的全功能模式,在字节码加载前,JProfiler就会将相关功能代码写入到需要分析的字节码中,会对JVM进程造成一定影响
优点是功能强大、通过这种方式采集的调用堆栈信息非常准确
缺点是如果要分析的系统的字节码很多,对系统性能的影响比较大,CPU的开销也比较大,为了节省性能开销,一般都要配合过滤器过滤掉JRE中现成的class以及框架中的class,配置JProfiler不对这些类进行分析
Sampling抽样模式
每隔一定时间比如5ms将每个线程对应虚拟机栈中的方法栈帧中的信息统计出来
只对内存泄漏、内存溢出进行分析使用Sampling抽象模式进行数据采集即可,对正在运行的JVM进程也推荐使用抽样模式,JProfiler本身也推荐使用抽样模式进行数据采集
重点关注线程、CPU、内存的情况
优点:这种数据采集模式即使不配置任何过滤器,对JVM进程的影响都非常小,对CPU的开销也非常低
缺点:无法提供JProfiler的全部功能,比如使用JProfiler查看某个方法的调用次数和执行时间等监控数据
Telemerties遥感监控:JProfiler在demo目录下提供了一些演示案例方便用户熟悉JProfiler的功能
Memory:监测内存随时间的变化,可以选择伊甸园区、老年代、幸存者区、代码缓存、压缩类、元空间以上不同的内存区域进行实时监测,也可以点击工具栏的Run GC强制执行垃圾回收
GC Activity:垃圾回收器随着时间变化是否活跃
Classes:当前JVM进程加载类的个数随时间变化情况,蓝色为CPU相关的类、绿色为非CPU相关的类
Threads:显示线程情况
CPU Load:程序运行期间CPU使用率随时间的变化,绿色是进程的CPU使用率,蓝色的是整个系统的CPU使用率
Live memory实时内存:
All Objects所有对象:展示每个类的类实例直方图以及当前类所有类实例占用的内存大小,这个内存大小是浅堆大小
点击工具栏的Mark Current就能统计点击以后每个当前类实例数和内存占用大小的变化,也可能因为垃圾回收行为导致实例数减少,而且能通过颜色区分新创建的对象和点击前的旧对象的实时变化
可以选择以类或者包作为统计的最小单元
通过直方图
关注频繁创建的对象,频繁创建的对象要关注是不是创建对象的线程死循环或者循环次数过多;
size和实例数变化都比较大说明创建对象过于频繁
关注大对象,比如读写文件时我们应该采取小缓冲区边读边写的方式,避免长时间只读不写导致缓冲区byte[]过大导致内存的利用率过低;
关注是否存在内存泄漏问题
一般每次垃圾回收后最低点的内存大小一直在稳步提升就说明大概率存在内存泄漏问题
Recorded Objects:
默认情况下没有开启该功能,一般只有怀疑可能存在内存泄漏的时候才会使用该功能做一些相关的分析,开启该功能对对象进行记录会严重降低系统性能
通过Recorded allocations设置每创建多少个对象记录一次被创建的对象,通过Aggregation level选择被记录的对象是按照类classes还是包package进行分组
选中一条记录右键选择change Liveness Mode可以选择展示存活对象、已经被垃圾回收对象,存活以及已经被垃圾回收的对象可以切换显示处于不同状态的被记录对象
我们可以从某个时间点开始抽样记录创建的对象,然后通过手动或者自动GC来判断是否存在一直在创建但是无法被回收的对象以及某些内存占用一直在增长却没有被回收的大对象[一般这种大对象也是因为大量小对象被创建且无法回收导致的]
选中一条记录右键选择Show Selection in Heap Walker对某个类的所有实例进行堆监测
Allocation Call Tree:
Allocation Hot Spots:
Class Tracker:
Heap Walker堆监测:
可以点击咖啡按钮对当前监控的JVM进程生成堆转储HPROF文件并且会在新窗口展示分析该堆转储文件,也可以只点击相机按钮弹窗新窗口对当前堆快照进行分析而不会生成HPROF文件
Classes:显示指定类记录,包含类实例个数和实例占用内存,
右键记录,选择Use Selected Objects在弹窗中的Reference选项可以选择Outgoing references或者Incoming references即出引用和入引用,可以查看每个实例被哪个对象引用,在哪个虚拟机栈的具体哪个方法被引用
右键每个实例记录,选择Show In Graph,会显示当前对象引用了那种类型的对象,当前对象又被哪种类型对象引用,点击其中某个对象右键选择Show Paths To GC Root还可以显示当前对象到GC Root的引用路径
CPU ViewerCPU监测:对CPU的追踪监控会对进程性能产生影响,默认是没有开启对应功能的
Call Tree[调用树]:可以只对指定线程中的方法进行性能监控,也可以对全部线程的方法进行性能监控,也可以筛选过滤指定状态的线程,可以对线程中的方法按照方法、类、包进行分组统计,会显示线程的方法调用树,显示每个方法被调用的时间以及占整个方法调用时间的百分比;其中inv[invoke]刻画方法被调用的次数,显示方法所在的类和方法名
Hot Spots:
Call Graph:
Method Statistics:
展示方法多次调用的总时间,调用次数、单次调用平均时间、单次调用中位数时间、单次调用最小时间和最大时间
Complexity Analysis:
Call Tracer:
Threads线程面板:
Thread History:展示进程中的线程和线程状态,能动态展示一段时间内线程状态的交替变化
线程分析一般关注三个方面
Web容器的线程最大数比如Tomcat的最大线程容量
线程是否长期处于阻塞状态
线程是否发生死锁,两个线程一直处于阻塞状态就是发生了死锁
Thread Dumps:点击可以生成当前时刻的线程dump文件
Monitors & locks同步监视器和锁面板:
Arthas
介绍:visualVM和JProfiler的优势是通过图形化界面可以看到各个维度的性能数据,缺点是都必须在被监控进程中配置监控参数,通过工具远程连接到项目进程获取数据;但是线上环境的网络一般是隔离的,本地的监控工具要连上线上环境很不方便,而且像JProfiler这种商业工具需要付费;这两款工具一般用于上线前的性能压力测试、代码调优;上线以后一般通过Arthas在服务端通过命令行进行调优和性能监控
Arthas是Alibaba开源的Java性能诊断工具,无需重启项目进程就能动态跟踪Java代码、实时监控JVM状态在线排查问题;Arthas支持JDK6+,支持Linux/Mac/Windows,采用命令行交互,支持Tab自动补全;Arthas是基于Java程序诊断工具Greys的二次开发,命令行实现基于termd开发,文本渲染功能基于crash的文本渲染功能开发,命令行界面基于vert.x提供的cli库开发,使用JavaAgent在JVM启动时加载Arthas的代理代码用于对JVM进程的动态监控和诊断[JavaAgent是运行在main方法前的拦截器,可以认为先执行JavaAgent的内定premain方法再执行main方法];使用ASM实现对类的字节码进行操作,比如热更新和方法追踪[ASM是Java字节码操作和分析框架,可以修改现有类或者以二进制形式动态生成类,提供常见的字节码转换和分析算法用于构建复杂字节码转换和代码分析工具,ASM被设计成小而快关注性能的字节码操作分析框架,非常适合在动态系统中使用,也支持在编译器中以静态方式使用];Arthas的Telnet Client代码源于Apache Commons Net;Arthas的profiler命令基于async-profiler实现用于性能分析和生成火焰图
官方文档:https://arthas.aliyun.com/zh-cn/
一般更习惯使用Arthas对线上运行的Java进程通过指令来进行监控
安装:
方式一:在linux操作系统上通过命令wget https://alibaba.github.io/arthas/arthas-boot.jar或者wget https://arthas.gitee.io/arthas-boot.jar下载arthas-boot.jar文件
通过java -jar arthas-boot.jar命令启动项目,启动后Arthas会检测当前服务器上的Java进程并将这些进程以列表的形式展示出来,用户输入指定进程对应的编号并回车指定Arthas要监控的java进程
也可以通过java -jar arthas-boot.jar [pid]启动Arthas并通过进程号直接指定要监控的Java进程
输入要监控的进程id后如果是首次启动Arthas还会自动联网下载相关依赖包
通过java -jar arthas-boot.jar -h可以查看Arthas启动命令的帮助文档
方式二:在浏览器直接点访问https://alibaba.github.io/arthas/arthas-boot.jar下载arthas-boot.jar到本地
卸载
linux平台下使用命令rm -rf ~/.arthas/以及rm -rf ~/logs/arthas删除指定目录文件
Windows平台直接删除user/home目录下的.arthas和logs/arthas目录
Arthas工程目录结构
| 模块名 | 功能 |
|---|---|
| agent | 基于JavaAgent的代理模块用于 JVM启动时加载Arthas的代理代码实现对Java应用的动态监控和诊断 |
| arthas-agent-attach | 探针模块 用于将 agent动态挂载到目标JVM上 |
| arthas-springboot-starter | 为SpringBoot项目提供JVM监听出口方便在SpringBoot应用中集成Arthas |
| arthas-vmtool | JVM工具类模块,实现vmtool命令用于获取虚拟机实例信息 |
| boot | Arthas的启动入口模块,包含启动控制台的逻辑,如解析命令行参数、下载依赖组件,是Java版本的意见安装启动脚本 |
| client | Java进程客户端连接模块用于建立客户端与服务端的连接,发送用户指令到服务端执行,并接收服务端的输出结果 |
| common | 公共类 存放Arthas整合如IO、文件、反射相关的工具类和一些枚举类 |
| core | 核心库 调用 Arthas内部如client、agent、spy等各个组件实现包括attach宿主应用进程、加载arthas-agent、实现核心命令以及与arthas-client通信等核心功能 |
| memorycompiler | 内存编译器模块,存放Arthas各类对象的编译信息类 |
| packaging | maven打包相关路径配置模块 |
| site | 使用文档模块,存放Arthas链接到JVM进程的使用文档 |
| spy | 增强接口模块,定义了接口,具体的实现在core模块中,用于实现类似SpringAOP的Advice,有前置方法、后置方法等 |
| tunnel-client | 客户端模块,用于管理客户端 |
| tunnel-common | 存放与tunnel相关的常量 |
| tunnel-server | 服务端模块,用于管理服务端 |
| tutorials | 存放一些问题记录和教程 |
| web-ui | 存放Web Console用到的前端资源。 |
Web Console
Arthas连接要监控的Java进程后,可以通过浏览器访问本机的8563端口,会展示一个和控制台界面相似的控制台来让用户使用Arthas命令
日志
通过命令cat ~/logs/arthas/arthas.log可以查看Arthas相关的日志文件
常用命令[以下命令都在Arthas控制台终端使用]
quit/exit:退出Arthas控制台终端
基础指令:
| 命令 | 功能 |
|---|---|
help | 显示Arthas常用命令和命令的功能具体指令比如 reset -h可以查看reset命令的用法 |
cat | 打印文件内容 类似 linux命令 |
echo | 打印参数 类似 linux命令 |
grep | 匹配查找包含指定字符的内容 类似 linux命令 |
tee | 将标准输入的数据同时输出到标准输出设备如终端和指定文件中 类似 linux命令 |
pwd | 返回当前工作目录 类似 linux命令 |
cls | 清空当前控制台屏幕 |
session | 查看被监控的Java进程和当前会话的sessionId |
reset | 将被Arthas增强过的类全部还原重置Arthad服务端关闭时也会重置所有被增强过的类 |
version | 打印当前使用Arthas的版本号 |
history | 打印此前输入过的历史命令 |
quit/exit | 退出当前Arthas客户端,但是其他Arthas客户端不受影响 |
stop/shutdown | 关闭Arthad服务端,所有Arthas客户端全部退出 |
keymap | 显示Arthas当前的快捷键映射表Arthas可以通过在当前用户目录下创建$USER_HOME/.arthas/conf/inputrc文件来自定义快捷键 |
JVM相关常用命令
| 命令 | 功能 |
|---|---|
dashboard | 展示实时数据面板 线程信息优先级、状态、CPU使用率等线程信息 堆和非堆内存信息和GC信息 JVM的运行时环境每间隔一段时间就会打印一次数据面板,使用 Ctrl+C停止打印回到Arthas控制台dashboard -i 500是指定实时数据面板打印时间间隔,单位是msdashboard -n 4是真顶实时数据面板的打印次数,单位是次 |
thread | 以列表的形式展示当前JVM进程中线程的个数状态信息,默认按CPU使用率对线程进行排序thread 1查看线程列表中编号为1的线程的运行情况thread -b查看JVM进程中处于阻塞状态的线程有哪些thread -i 5000统计指定5s时间内所有线程对CPU的使用率thread -n 2显示线程列表中CPU占用率前两位的线程的详细信息 |
sysprop | 列举JVM系统属性和属性值 |
sysenv | 列举JVM的环境变量 |
getstatic | 查看类的静态属性 |
heapdump | heapdump /tmp/test.hprof导出当前监控系统的堆转储文件heapdump --live /tmp/test.hprof是只导出当前Java进程活跃对象的堆转储文件 |
类和类加载器相关常用命令[以下命令的官方文档参考如https://arthas.aliyun.com/doc/jad]
| 命令 | 功能 |
|---|---|
sc | 查看JVM中已加载的类信息-d输出当前类的原始文件来源、类加载器等详细信息,如果一个类被多个类加载器加载会显示多次-E开启正则表达式匹配,默认为通配符匹配-f额外输出当前类的成员变量,需要和-d参数一起使用-x指定输出静态变量时对属性的遍历深度,默认为0,相当于直接使用toString输出 |
sm | 查看已加载类的方法信息,无法查看父类声明的方法sm 全类名会展示指定类声明的全部方法sm 全类名 方法名会展示对应方法,形参列表和返回值类型-d展示每个方法的详细信息-E开启正则表达式匹配,默认为通配符匹配 |
jad | 反编译指定已加载类的源码jad java.lang.String能输出反编译后的String类的Java源码jad 全类名 方法名能输出反编译后的对应方法的Java源码 |
mc | 内存编译器,能将java文件编译为字节码文件,通常和redefine一起使用来替换掉JVM已加载的类mc /tmp/Test.java编译Test.java生成对应字节码文件,会显示编译后的字节码文件所在目录 |
redefine | 替换掉JVM中相同类名的类redefine /root/IdeaProjects/MyDemo/HelloWorld.class加载新字节码文件替换掉JVM进程中的同名类官方建议使用 retransform命令替代redefine命令 |
classloader | 显示当前JVM进程中的类加载器、类加载器个数和对应加载了多少个类-t以继承树的形式展示所有类加载器的继承关系-l按类加载器实例查看类加载器的统计信息-c 类加载器hashCode使用类加载器对应hashCode查看该类加载器能加载哪些jar包的字节码 |
方法相关命令
| 命令 | 功能 |
|---|---|
monitor | 方法执行监控,这是非实时返回命令,不是方法执行结束立即就能更新统计结果,每个统计周期打印一次 可以针对每个类的所有方法 monitor 全类名或者指定方法monitor 全类名 方法名对方法的调用情况进行监控,统计方法的调用次数、执行时间和失败率等数据-c设置方法执行数据的统计周期,单位是秒,默认是120s |
watch | 对方法执行数据如返回值、入参、抛出异常进行观测,通过OGNL表达式查看指定变量watch 全类名 方法名显示指定方法每次调用的执行时刻、花费时间、方法执行信息中的result默认包含入参值、和返回值watch 全类名 方法名 "{params,returnObj}" -x 2方法执行信息中的result只包含入参值和返回值-x指定输出结果的属性的遍历深度,默认深度为1 |
trace | 显示方法内部方法的调用路径,并输出路径上每个方法调用节点的耗时trace 全类名 方法名显示方法每次调用的时刻、被调用的线程名、线程id、是否为守护线程、线程优先级、加载当前类的类加载器、方法的执行耗时-n只输出指定数量条方法调用记录 |
stack | 输出当前方法的被调用路径stack 全类名 方法名显示方法每次调用的时刻、被调用的线程名、线程id、是否为守护线程、线程优先级、加载当前类的类加载器以及方法在Java源码中被调用的位置-n只输出指定数量条方法调用记录 |
tt | tt是TimeTunnel的缩写,记录指定方法每次调用的入参和返回值信息-t表明希望记录每次指定方法的执行情况-n 3只输出指定数量条方法调用记录tt -t 全类名 方法名显示方法调用时刻、花费时间、方法所属类信息 |
火焰图相关
profiler命令生成火焰图,包含启动profiler、获取样例、查看profiler状态、停止profiler,生成svg格式或者html格式的火焰图
生成火焰图需要先通过命令profiler start启动profiler;通过profiler getSamples命令获取样例数据,该命令返回获取的样例数据个数;通过profiler status查看profiler的执行状态;通过命令profiler stop --file /tmp/output.svg指定火焰图的生成路径生成火焰图并停止profiler,默认生成的就是svg格式的火焰图,没有设置生成位置默认会将火焰图生成在目录/tmp/demo/arthas-output/xxx.svg;可以通过命令profiler stop --format html指定生成的火 焰图格式为html
火焰图支持从浏览器地址http://localhost:3658/arthas-output/访问3658端口访问,会显示此前生成的所有火焰图
Arthas设置相关
options命令用于查看或者设置Arthas全局开关
等等[Arthas的命令还有相当多,作为一个性能监控工具学习成本也有亿点点高]
JMC[Java Mission Control]
介绍:JDK从JDK7u40开始在bin目录下提供了JMC工具,JFR[Java Flight Recorder]飞行记录仪是JMC中的一部分,JFR从JDK11开始开源,此前属于商业版特性,JFR功能需要使用JVM参数-XX:+UnlockCommercialFeatures开启
官方文档:https://github.com/JDKMission Control/jmc
直接点击jmc.exe就能打开jmc客户端可视化界面
JMC用于对Java进程的管理、监视、概要分析和故障排查,包含一个GUI客户端和众多收集Java虚拟机性能数据的插件,比如访问虚拟机各子系统运行数据的MXBeans的JMX Console以及profiling工具JFR,JMC采用取样而非代码植入的方式采集数据,对被监控系统性能的影响非常小,完全可以开着JMC做压测,唯一可能对JVM进程造成的影响是Full GC变多了
如果连接的是远程JVM进程,远程JVM进程需要使用下列参数开启JMX,在JMC客户端点击文件--连接--创建新连接填入JMX参数中的host和port
xxxxxxxxxx-Dcom.sun.management.jmxremote.port=${YOUR PORT}-Dcom.sun.management.jmxremote-Dcom.sun.management.jmxremote.authenticate=false-Dcom.sun.management.jmxremote.ssl=false-Djava.rmi.server.hostname=${YOUR HOST/IP}MBean服务器
概览面板
默认会实时记录展示Java堆内存占用、CPU使用率,用户可以自定义仪表盘要展示的各种数据
展示机器的CPU使用率和JVM进程的CPU使用率的折线图、内存占用的折线图,都可以自定义展示要观测数据
触发器面板
可以设置触发器在CPU占用过低过高、发生死锁、线程数量太多时都可以触发报警
内存
展示各个内存区域的详细信息,GC信息
线程
展示JVM中的所有线程状态、CPU占用率等线程信息
飞行记录器
JFR会监控显示JVM进程的系统属性、内存信息、GC信息;代码面板下能看到热点包、热点类、热点方法、调用树、异常错误等;线程面板展示线程和线程状态
飞行记录器的设置固定时间记录最好大于等于1min,事件设置最好选择Continuous[Profiling会占用更多的服务器资源]
飞行记录器的配置方法用到时再检索重新总结,老师讲的太简陋
使用JFR除了要使用JVM参数-XX:+UnlockCommercialFeatures,还要增加JVM参数-XX:+FlightRecorder,否则启动JFR会报错
JFR能以极低性能开销收集Java虚拟机的性能数据,默认配置下性能开销平均低于1%
飞行记录器会记录运行过程中发生的一系列事件,包含java层面的线程、锁事件,JVM内部的新建对象、垃圾回收和即时编译事件,JFR将这些事件分为四种类型
瞬时事件:比如是否出现异常、线程启动这种发生与否的事件
持续事件:比如垃圾回收这种持续一段时间的事件
计时事件:时长超出指定阈值的持续事件
取样事件:周期性取样的事件,比如方法取样,每隔一段时间统计每个线程的栈轨迹,检查栈轨迹中是否存在一个反复出现的方法
启动方式
方式一:运行目标Java程序时添加参数如java -XX:StartFlightRecording=delay=5s,duration=20s,filename=myrecording.jfr,settings=profile MyApp,执行上述命令JVM启动5s[delay=5s]后JFR开始收集数据,持续20s[duration=20s],收集玩数据后,JFR会将收集的数据保存在指定文件中[filename=myrecording.jfr]
如果不对JFR持续收集数据的过程加以限制,JFR可能会填满硬盘所有空间,可以在命令中添加如下参数对收集的数据进行限制java -XX:StartFlightRecording=delay=5s,duration=20s,filename=myrecording.jfr,settings=profile,maxage=10m,maxsize=100m,name=SomeLabel MyApp,其中maxage=10m表示超过10分钟JFR就不工作了,maxSize表示数据文件超过100m就不收集数据了,maxage和maxSize只要有一个条件达成都不会再收集数据
方式二:使用jcmd的JFR子命令来启动关闭JFR以及使用JFR收集数据
jcmd <PID> JFR.start settings=profile maxage=10m maxsize=150m name=SomeLabel启动JFR,注意此时JFR已经开始收集数据
使用命令jcmd <PID> JFR.dump name=SomeLabel filename=myrecording.jfr导出已经收集的数据
使用命令jcmd <PID> JFR.stop name=SomeLabel关闭目标进程中的JFR
方式三:在JMC的图形化界面的Harness jython中右键FilghtRecorder选择Start Flight Recording直接启动
其他工具
Flame Graphs火焰图:
介绍:火焰图是直观展示CPU在程序整个生命周期中时间分配的工具,可以直观第显示调用栈的CPU消耗瓶颈,一般用于查找接口的性能瓶颈
Java火焰图的网上大部分讲解来自于Brendan Gregg的博客http://www.brendangregg.com/flamegraphs.html,需要用到火焰图的时候可以看这个人的博客进行学习
火焰图的x坐标是时间,看火焰图重点是关注某一层调用栈时间上的占比,每一个小框表示一个栈帧,纵轴表示一个虚拟机栈
Tprofiler:
介绍:阿里提供的开源工具TProfiler可以定位对性能影响很大的代码
一般系统的性能瓶颈包括创建了过多的静态对象、大量业务线程频繁创建一些生命周期很长的临时对象
TProfiler性能也很强大,目前受欢迎程序越来越广
TProfiler最重要的特性是能统计出指定时间段内JVM的top method[热点方法],这些top method极有可能就是造成JVM性能瓶颈的元凶,这是绝大多数JVM调优工具不具备的功能,JRockit的首席开发者Marcus Hirt在私人博客《Low Overhead Method Profiling with Java Mission Control》明确指出JRMC不支持TOP Method的统计
下载地址:https://github.com/alibaba/TProfiler
BTrace是SUN Kenai云计算开发平台下的开源项目,用于动态追踪Java进程的工具,也会将性能监控代码注入到JVM的字节码来采集程序运行数据
YourKit
JProbe
Spring Insight:基于Spring开发的系统可以尝试使用Spring Insight
内存溢出分析
概念:为对象分配内存空间时即使在Full GC后仍然没有足够的内存可以使用就叫内存溢出
大规模请求场景下,tomcat可能因为无法承受请求压力而发生内存溢出错误
在堆空间较小的情况下,tomcat标准管理器中ConcurrentHashMap类型的sessions属性,该属性中保存了每个用户的session数据,通过OQL语句可以查询session个数,计算session占用堆空间的比例来判断是否是短时间内用户数量太多导致session占用内存太大造成的内存溢出,我们还可以对每个session的创建时间做统计,计算不同百分比session的创建速度从而检测判断内存溢出是否由短时间大量创建用户Session导致
我们可以直接从MAT分析dump文件排查这些可疑的内存泄漏或者内存溢出点
内存泄漏分析
内存泄漏概念:对象已经不再使用,但是可达性分析算法判断对象仍然可触及,JVM误以为该对象仍然在使用中无法被回收造成内存泄漏;典型就是一个对象的可支配树子树已经不再需要被使用了,但是仍然被其他对象或者类变量直接或者间接引用
严格意义上的内存泄露就是上面已经不会再被程序使用,但是GC又无法回收他们
宽泛意义上内存泄漏指开发者的一些不好的实践或者疏忽导致对象的生命周期不必要地变得很长导致内存紧张甚至造成内存溢出
像TLAB中总有一部分内存无法被使用也可以看成是一种内存泄漏
内存泄漏的增多最终会导致内存溢出
注意像while(true){}循环体中定义的局部变量并且没有发生逃逸,每执行完一次循环这些局部变量都会变成垃圾可以被回收
内存泄露的分类
经常发生:每次执行一段代码都会导致一块内存的泄露
偶然发生:在特定情况下才会发生,比如数据库连接、IO流等资源没有被正确关闭,比如关闭代码不在finally块中但是出现了异常无法执行资源关闭代码
一次性:发生内存泄漏的方法只会被执行一次
隐式泄露:一直占着内存不释放直到进程运行结束
内存泄漏的八种情况
静态集合类容器比如hashMap、LinkedList的生命周期和JVM进程一致,容器中的对象不手动移除在JVM进程结束前不能被释放从而导致内存泄漏,但是容器中的部分对象永远也不会再被程序使用;这是短生命周期对象被长生命周期对象持有导致的内存泄漏
单例模式,单例对象一般也是静态的,生命周期和JVM进程一样长,单例对象如果持有外部对象的引用,外部对象也不会被回收造成内存泄漏
内部类持有外部类,一个外部类实例的方法返回一个内部类实例,该方法返回的内部类实例被一个长生命周期对象持有,即使外部类实例对象不再被程序使用,该外部类实例也无法被垃圾回收造成内存泄漏
数据库连接、网络IO流、本地IO流这种连接资源不再使用时都需要手动调用资源对象的close方法释放连接,只有连接被关闭后垃圾收集器才会回收对应的资源对象,访问数据库如果Connection、Statement、ResultSet不显示关闭会造成大量对象无法被回收引起内存泄漏
变量不合理的作用域,一个变量定义的作用范围大于变量的使用范围,且没有及时被置为null,很有可能导致内存泄漏,比如一个对象只在某个方法中被使用,只需要设置成局部变量,但是却被设置成成员变量,在方法中被创建然后赋值给成员变量,方法执行结束后也没有将成员变量置为null造成内存泄漏
对对象的修改改变了对象的哈希值,一个存入哈希表结构集合的对象,如果对象中有字段参与了哈希值的计算,而我们又修改了这些字段,会导致对象的当前哈希值和对象存储在集合中的位置不匹配,即使是使用contains方法使用当前对象的引用作为参数去hashSet集合中检索对象也无法找到该对象,这也会导致无法从HashSet集合中单独删除该对象造成内存泄漏[因为删除页需要根据对象的当前哈希值去找节点位置]
而且这种情况会导致hashSet中存放两个相同的节点指向同一个对象,情节比较恶劣
默认的对象的hashCode方法继承自Object类,通过对象的内存地址计算哈希值,但是这种方式存在局限性,因为通常两个对象的属性值完全相同我们就会认为两个对象相同,因此一般重写hashCode方法,而且集合本身也依赖hashCode()和equals方法来区分对象,而且重写hashCode方法必须同时重写equals方法,因为要保证两个对象equals方法认为相等,那么hashCode方法得到的哈希值也必须相等;因此使用集合管理对象经常会考虑两个对象属性值完全相同就相同,并且同时重写equals和hashCode方法,默认重写的hashCode就是会根据属性值计算哈希值,此时就要特别注意,一旦重写了HashCode且已经被集合管理的对象,只要对集合中的对象修改了属性[这是非常常见的操作],这个对象就无法删除也无法再从集合中找到,因此查找和删除都会使用对象当前的哈希值,但是对象的存储位置还是使用的旧哈希值[但是感觉哈希表扩容的时候会自动修正啊]
此外重写了hashCode并已经使用集合进行管理的对象,一旦对属性进行了修改,再次向HashSet中添加属性完全相同的对象仍然能够正常添加,这是因为新的哈希值对应桶上不一定有相同对象;此外采用最初的属性值的对象添加到集合中发现哈希值对应位置上已经有元素[节点中好像会保存对象的哈希值,不保存的话比较哈希值也不一样,但是不影响也会直接覆盖]但是调用equals方法比较两个对象时不等会直接将修改了属性的对象直接覆盖掉,如果没有对修改属性的对象重新保存,旧的被修改的对象就直接丢失了[lombok的@Data注解会重写getter、setter、toString、equals、hashCode、无参构造器和全参构造器,因此使用了该注解的对象在使用集合管理时千万不要修改集合中对象的属性,改了集合中的对象就找不到也删不了,如果没有额外保存一份再添加旧属性完全相同的对象时会直接将修改后的对象直接覆盖掉]
像String类被设置成不可变类型就不会存在修改属性值会导致hashCode变化的问题,可以放心地使用HashSet或者hashMap来进行管理
缓存导致的内存泄露,放入缓存的对象很容易被遗忘,可能会导致项目启动非常慢并且发生OOM,因为测试环境的缓存数据量一般都不大,但是生产环境缓存的数据量不好控制,缓存的数据量可能非常大导致数据加载时间长,内存占用过大造成内存泄漏
可以对缓存对象使用弱引用,使用WeakHashMap管理缓存数据,除了缓存集合没有其他非软引用指向缓存对象,缓存集合会自动丢弃缓存让缓存对象被回收;注意WeakHashMap只有key使用的是弱引用,值使用的是强引用,因此WeakHashMap中的对象除了集合本身对key的弱引用外,key对应对象没有其他引用,集合会自动丢弃该键值对让键值对对应对象自动被垃圾回收
监听器和回调引起的内存泄漏
客户端在系统监听器API中注册回调,但是没有显示取消,回调对象就会累积,比较好的方案还是将回调对象保存为WeakHashMap的键,用缓存集合使用软引用来指向回调对象让其不被使用时自动被回收
过期引用
弹栈操作只是将指针指向了栈顶元素的下一个元素,栈中被弹出的元素并没有被置空,栈中仍然保存已弹栈对象的引用,这种情况就称为过期引用,过期引用导致的内存泄漏问题比较隐蔽
常见JVM参数
在oracle官网https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html提供了对各种参数的说明文档,整个文档的参数有六百多个
自己总结的常用JVM参数
-XX:+PrintFlagsInitial:打印所有JVM参数的默认初始值
-XX:+PrintFlagsFinal:打印所有JVM参数的实际值,:=符号表示实际值不等于默认初始值
jinfo -flag 参数名 进程号:打印指定JVM进程中指定参数名的实际参数值
比如使用jinfo -flag UseParallelGC 924查看JVM是否使用Parallel GC,返回的结果是-XX:+UseParallelGC表示正在使用Parallel;同理对Parallel Old有jinfo -flag UseParallelOldGC 924,结果为-XX:+UseParallelOldGC,如果没有使用指定垃圾回收器会返回如-XX:-UseParallelGC
-Xms:设置堆空间的初始内存,默认物理内存的1/64
-Xmx:设置堆空间的最大内存,默认物理内存的1/4
-Xmn:设置新生代的内存
-XX:NewRatio:设置老年代容量是新生代容量的多少倍
-XX:SurvivorRatio:设置新生代中伊甸园区容量是单个幸存者区容量的多少倍
幸存者区太小会导致很多对象不经过多次YGC就直接进入老年代,让分代设计和YGC失去效果
如果伊甸园区设置的太小会导致频繁地发生YGC频繁STW影响系统性能
-XX:MaxTenuringThreshold:设置新生代对象的年龄计数器晋升老年代的计数阈值
-XX:+PrintGCDetails:打印详细的GC处理日志和堆空间内存情况,不同的GC打印信息不同,参数优先级比下面一个参数更高,显示的内容依次为GC类型、GC原因、新生代使用的垃圾收集器和新生代占用内存前后变化、老年代使用GC和老年代占用内存GC前后变化、整个堆占用内存GC前后变化、方法区占用内存GC前后变化、GC持续时间;Times中user是垃圾收集器花费的所有CPU时间[用户态用时,不包含其他进程的执行时间即垃圾回收线程阻塞的时间]、sys是花费在等待系统调用或者系统事件的时间[系统内核态用时]、real是从GC开始到GC结束的整个持续时间[包含和其他线程抢夺CPU时间片以及等待的时间,一般real会小于user+sys,这是因为现在一般使用的都是多核CPU,如果real>user+sys说明IO负载比较重或者CPU核数不够用]
Serial显示新生代名字为[DefNew;ParNew显示新生代名字为[ParNew;Parallel显示新生代名字为[PSYoungGen;Parallel Old显示老年代的名字为[ParOldGen;G1显示garbage-first heap
Allocation Failure:表示本次GC是由于没有足够空间存储新数据产生的
堆信息依次显示新生代def new generation;老年代tenured generation;永久代compacting perm gen;元空间MetaSpace的最大可用内存和已使用的内存,还会显示每个区域的虚拟内存开始和结束的地址
-XX:+PrintGC/-verbose:gc:打印简要的GC日志信息,不会打印堆空间的内存情况,显示的内容依次为GC类型、GC原因、GC前后可用内存变化以及堆区总内存,GC持续的时间
-XX:+PrintGCTimeStamps:以基准时间的格式输出GC的时间戳
在GC日志前面加上GC发生时距离JVM启动后的以秒为单位的时间长度
-XX:+PrintGCDateStamps:以日期时间的格式输出GC的时间戳
在GC日志前面加上GC发生时的实际带时区时间和日期,一般使用日志分析工具会加上GC的时间戳
-XX:+PrintHeapAtGC:在GC前后打印堆的信息
-Xloggc:./logs/gc.log:把GC日志输出到指定路径的文件中
.表示当前路径,这个当前路径是指当前工程的根目录,logs目录必须提前创建,否则JVM启动会报错
常用的GC日志分析工具有GCViewer、GCEasy、GCHisto、GCLogViewer、Hpjmeter、garbagecat
GCViewer:github上能下载jar包,这个jar包双击就能运行,直接导入GC日志文件就行
GCEasy:官网gceasy.io,直接把日志文件上传点击分析就能在线分析日志信息,GCEasy的信息显示的相对来说更全面一些
-XX:HandlePromotionFailure:是否设置空间分配担保
发生YGC前,JVM会检查老年代的最大可用连续空间是否大于新生代所有对象的总空间,如果大于说明本次YGC是安全的,如果小于则说明本次YGC存在风险,JVM会检查系统参数HandlePromotionFailure的设置值是否允许担保失败
如果HandlePromotionFailure=true表示允许担保失败,JVM会检查老年代连续可用空间是否大于历次晋升到老年代的对象的平均大小,如果老年代大于平均值,就尝试一次YGC,但是本次YGC还是有风险的;如果老年代小于平均值,说明本次YGC出事的概率很大,此时将YGC改为进行一次Full GC
如果HandlePromotionFailure=false表示不允许担保失败,JVM检查到老年代的最大可用连续空间小于新生代对象占用的总空间会直接进行Full GC,不会再去检查老年代内存是否大于历次晋升老年代对象的平均大小从而发起YGC的尝试
在JDK7及以后,该HandlePromotionFailure就失效了,不会再影响虚拟机的空间分配担保策略,默认就是允许担保失败;JDK6 Update24版本以后OpenJDK的源码中已经不使用该参数而是直接使用该参数为true的规则,只要YGC前老年代的连续可用空间大于新生代对象总大小或者大于历次晋升对象的平均大小就会进行YGC,否则将YGC改为Full GC
-XX:+PrintEscapeAnalysis查看逃逸分析的筛选结果
-XX:+PrintCommandLineFlags:在控制台打印JVM参数,包含了JVM使用的垃圾回收器参数如默认的-XX:+UseParallelGC,JVM在确认新生代使用Parallel后老年代会自动使用Parallel Old
-XX:+TraceClassLoading:配置该JVM参数可以追踪打印类的加载信息,该参数会打印所有加载过的类的日志,没啥代码的情况下也会打印一千行左右
JVM运行时参数
JVM参数选项类型:
1️⃣标准参数选项:
-X和-XX参数选项都是非标准参数
特点是稳定,基本不会随着JDK版本迭代发生变化,形式上以-打头,可以通过java -h输出的就是标准参数,标准参数开发者一般使用的比较少
32位windows操作系统上必须保证至少两个以上CPU和2G以上物理内存才能使用Server模式,64位操作系统只支持Server模式
Client模式使用C1编译器对字节码的优化比较简单编译耗时短,优化方式只考虑方法内联、去虚拟化、冗余消除
Server模式使用C2编译器对字节码的优化比较激进,编译耗时长,代码运行更高效,除了考虑方法内联、去虚拟化、冗余消除以外还支持逃逸分析,基于逃逸分析做标量替换、栈上分配、同步消除
xxxxxxxxxxC:\Windows\system32>java -h用法: java [-options] class [args...] (执行类) 或 java [-options] -jar jarfile [args...] (执行 jar 文件)其中选项包括: -d32 使用 32 位数据模型 (如果可用) -d64 使用 64 位数据模型 (如果可用) -server 选择 "server" VM 默认 VM 是 server.
-cp <目录和 zip/jar 文件的类搜索路径> -classpath <目录和 zip/jar 文件的类搜索路径> 用 ; 分隔的目录, JAR 档案 和 ZIP 档案列表, 用于搜索类文件。 -D<名称>=<值> 设置系统属性 -verbose:[class|gc|jni] 启用详细输出 -version 输出产品版本并退出 -version:<值> 警告: 此功能已过时, 将在 未来发行版中删除。 需要指定的版本才能运行 -showversion 输出产品版本并继续 -jre-restrict-search | -no-jre-restrict-search 警告: 此功能已过时, 将在 未来发行版中删除。 在版本搜索中包括/排除用户专用 JRE -? -help 输出此帮助消息 -X 输出非标准选项的帮助 -ea[:<packagename>...|:<classname>] -enableassertions[:<packagename>...|:<classname>] 按指定的粒度启用断言 -da[:<packagename>...|:<classname>] -disableassertions[:<packagename>...|:<classname>] 禁用具有指定粒度的断言 -esa | -enablesystemassertions 启用系统断言 -dsa | -disablesystemassertions 禁用系统断言 -agentlib:<libname>[=<选项>] 加载本机代理库 <libname>, 例如 -agentlib:hprof 另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help -agentpath:<pathname>[=<选项>] 按完整路径名加载本机代理库 -javaagent:<jarpath>[=<选项>] 加载 Java 编程语言代理, 请参阅 java.lang.instrument -splash:<imagepath> 使用指定的图像显示启动屏幕有关详细信息, 请参阅 http://www.oracle.com/technetwork/java/javase/documentation/index.html。2️⃣-X参数选项:
特点也是相对比较稳定,基本不会随着JDK版本迭代发生修改或者弃用,相对来说-XX的变化可能非常大,形式上以-x打头,可以通过命令java -X打印具体参数
-Xmixed:默认JVM就是混合模式,混合模式是JVM的即时编译器和解释器是同时协同工作[解释器程序启动快,只针对热点代码的超过热点代码执行频率超过判断阈值编译成本地代码缓存],如果我们希望程序运行只使用解释器可以配置命令参数-Xint禁用掉即时编译器[所有的字节码都需要被解释执行],如果我们希望程序运行只使用即时编译器可以配置命令参数-Xcomp禁用掉解释器[所有方法字节码第一次使用都一次性被编译成本地代码生成缓存,然后再直接调用缓存执行];混合模式是现代的主流模式,语言的执行效率主要看编译器的设计
-Xms<size>:设置堆内存初始大小,等价于JVM参数-XX:InitialHeapSize;-Xmx<size>:设置堆内存最大大小,等价于JVM参数-XX:MaxHeapSize;-Xss<size>:设置虚拟机栈大小,等价于JVM参数-XX:ThreadStackSize;
<size>表示指定参数时需要填写具体的参数值
xxxxxxxxxxC:\Windows\system32>java -X -Xmixed 混合模式执行 (默认) -Xint 仅解释模式执行 -Xbootclasspath:<用 ; 分隔的目录和 zip/jar 文件> 设置搜索路径以引导类和资源 -Xbootclasspath/a:<用 ; 分隔的目录和 zip/jar 文件> 附加在引导类路径末尾 -Xbootclasspath/p:<用 ; 分隔的目录和 zip/jar 文件> 置于引导类路径之前 -Xdiag 显示附加诊断消息 -Xnoclassgc 禁用类垃圾收集 -Xincgc 启用增量垃圾收集 -Xloggc:<file> 将 GC 状态记录在文件中 (带时间戳) -Xbatch 禁用后台编译 -Xms<size> 设置初始 Java 堆大小 -Xmx<size> 设置最大 Java 堆大小 -Xss<size> 设置 Java 线程堆栈大小 -Xprof 输出 cpu 配置文件数据 -Xfuture 启用最严格的检查, 预期将来的默认值 -Xrs 减少 Java/VM 对操作系统信号的使用 (请参阅文档) -Xcheck:jni 对 JNI 函数执行其他检查 -Xshare:off 不尝试使用共享类数据 -Xshare:auto 在可能的情况下使用共享类数据 (默认) -Xshare:on 要求使用共享类数据, 否则将失败。 -XshowSettings 显示所有设置并继续 -XshowSettings:all 显示所有设置并继续 -XshowSettings:vm 显示所有与 vm 相关的设置并继续 -XshowSettings:properties 显示所有属性设置并继续 -XshowSettings:locale 显示所有与区域设置相关的设置并继续
-X 选项是非标准选项, 如有更改, 恕不另行通知。3️⃣-XX参数选项:
一般开发者使用该参数比较多,参数数量也是最多的,这类参数是实验性的,可能随着版本迭代发生修改或者移除[比如垃圾收集器CMS被废弃对应的JVM参数也会废弃],形式上以-XX打头,主要作用是为了开发和调试JVM
这些类型参数可以分成两种格式
Boolean类型格式:-XX:+<option>或者-XX:-<option>表示启用或者禁用option属性,默认情况下有很多禁用或者启用的配置
比如-XX:-UseParallelGC、-XX:+UseG1GC、-XX:+UseAdaptiveSizePolicy[自动调整伊甸园区和幸存者区的比例以满足最低暂停时间和控制GC频率的目的,比手动显示指定伊甸园区和幸存者区的比例更好]
非Boolean的k-v类型
数值类型-XX:<option>=<number>
-XX:NewSize=1024m:设置新生代初始大小为1024M
-XX:MaxGCPauseMillis=500:设置GC暂停时间为500ms
-XX:GCTimeRatio=19:设置垃圾收集器的吞吐量
-XX:NewRatio=2:设置新生代和老年代的比例
非数值类型-XX:<name>=<string>
-XX:HeapDumpPath=/usr/local/heapdump.hprof指定导出的堆转储文件的存储路径
特别JVM参数
-XX:+PrintFlagsFinal:输出所有参数的名称和实际值,默认不包含诊断性和试验性质的参数,可以配合-XX:+UnlockDiagnosticVMOptions和-XX:+UnlockExperimentalVMOptions输出诊断性和实验性的参数实际值,=表示实际值采用默认值,:=表示实际值不是默认值,实际值可能是JVM根据实际情况修改的,也可能是用户指定的
添加JVM参数的方式
Eclipse: 右键@Test--Run As--Run Configurations--在Arguments面板的Program arguments传递main方法的形参,在VM arguments传递JVM参数
IDEA:Run--Edit Configuration--在VM options设置JVM参数
运行jar包:java -Xms50m -Xmx50m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -jar demo.jar
通过Tomcat运行war包,linux系统下可以在tomcat/bin/catalina.sh中添加JVM参数JAVA_OPTS="-Xms512M -Xmx1024M",Windows下可以在catalina.bat中添加JVM参数set "JAVA_OPTS=-Xms512M -Xmx1024M"
程序运行过程中可以使用jinfo设置JVM参数,jinfo -flag <name>=<value> <pid>设置非Boolean类型参数,jinfo -flag [+|-]<name> <pid>设置Boolean类型参数
注意jinfo改堆内存大小不能使用-Xms,也不能使用InitialHeapSize,这是因为JVM一旦启动有些参数是不能进行修改的,像堆初始大小和使用哪种垃圾收集器都是不能修改的,能修改的之前说过很少只有十几个,可以使用命令java -XX:+PrintFlagsFinal -version |grep manageble查看被标记为manageable参数
常用JVM参数
打印XX选项和值
-XX:+PrintCommandLineFlags:在程序运行前打印用户手动设置或者JVM自动设置的-XX参数
-XX:+PrintFlagsInitial:打印出所有-XX参数的默认值
-XX:+PrintFlagsFinal:打印出-XX参数在运行时的实际值
-XX:+PrintVMOptions:打印JVM参数
此外还可以使用jinfo命令查看具体某一个JVM参数值
虚拟机栈
-Xss128k:设置每隔线程对应虚拟机栈的大小为128k,等价于-XX:ThreadStackSize=128k
堆内存
-Xms3550m:设置JVM堆内存初始大小为3550MB,等价于-XX:InitialHeapSize=3550m
-Xmx3550m:设置JVM堆内存最大大小为3550MB,等价于-XX:MaxHeapSize=3550m
-Xmn2g:设置年轻代的大小为2G,官方推荐配置为整个堆大小的3/8,等价于-XX:newSize=2g,该参数会同时设置新生代初始大小和最大大小都为2G,
默认情况下新生代占整个堆区的1/3,老年代占整个堆区的2/3[这个比例和实际情况是一样的]
伊甸园区占新生代的8/10,单个幸存者区占新生代的1/10[实际的比例为6:1:1,与默认的8:1:1不同,通过打印对应的JVM参数-XX:SurvivorRatio发现实际值确实是8,默认情况下-XX:+UseAdaptiveSizePolicy自动配置各个区的大小即自适应内存大小策略是开启的,但是关闭以后伊甸园区和幸存者区还是6:1:1,要想强制设置伊甸园区和幸存者区的比例为8:1:1必须通过设置参数-XX:SurvivorRatio=8显示指定,在设置了-XX:SurvivorRatio=8的情况下即使设置了-XX:+UseAdaptiveSizePolicy也会按照-XX:SurvivorRatio=8来分配伊甸园区和幸存者区的比例,建议让JVM自动调整来尽可能满足垃圾收集的暂停时间指标]
-XX:NewSize=1024m:设置年轻代的初始内存大小为1024MB
-XX:MaxNewSize=1024m:设置年轻代的最大内存大小为1024M
-XX:SurvivorRatio=8:设置伊甸园区域一个幸存者区的比值,默认为8
-XX:+UseAdaptiveSizePolicy:JVM自动设置各个内存区域的大小比例
-XX:NewRatio=4:设置老年代相较于年轻代的内存大小比例,默认值是2,即老年代大小为新生代的两倍
-XX:PretenureSizeThreadshold=1024:默认单位为字节,设置让大于此阈值的对象直接分配在老年代,该参数只对Serial、ParNew有效
-XX:MaxTenuringThreshold=15:默认值为15,设置幸存者区对象晋升老年代的存活年龄阈值,新生代每次MinorGC后存活对象的年龄都会+1
-XX:+PrintTenuringDistribution:JVM每次MinorGC后都打印当前正在使用中的Survivor中的对象年龄分布
-XX:TargetSurvivorRatio:设置minorGC结束后幸存者区中占用空间的期望比例
永久代
-XX:PermSize=256m:设置永久代初始内存为256MB
-XX:MaxPermSize=256m:设置永久代最大内存为256MB
元空间
-XX:MetaspaceSize:设置元空间初始内存大小
-XX:MaxMetaspaceSize:设置元空间最大内存大小
-XX:+UseCompressedOops:启用压缩对象指针
-XX:+UseCompressedClassPointers:启用压缩类指针
-XX:CompressedClassSpaceSize:设置Klass metaspace的大小,默认是1G
直接内存
-XX:MaxDirectMemorySize:设置直接内存的大小,默认与Java堆内存最大大小一致
注意这里的直接内存大小不包括元空间,是JVM能通过比如NIO访问的直接内存大小,老师后来提到元数据区、直接内存是本地内存中互斥的两个部分,JVM进程占用的内存为堆内存加上元空间加上直接内存的总和
OOM相关JVM参数
-XX:+HeapDumpOnOutOfMemoryError:当内存出现OOM时,自动生成堆转储dump文件方便后续分析,默认未开启
生成dump文件用于分析导致OOM的原因
-XX:+HeapDumpBeforeFullGC:在每次FullGC以前自动生成堆转储dump文件,默认未开启
生成dump文件用于分析Full GC发生的原因
-XX:HeapDumpPath=<path>:指定堆转储文件的存储路径和文件名
默认是JVM的工作目录下,工作目录指java启动命令所在目录,或者代码System.getProperty("user.dir")的返回值也是工作目录,如果生成的hprof文件的名字相同会在文件后缀后加.1、.2...
-XX:OnOutOfMemoryError:指定一个可执行程序或者脚本的路径,发生OOM时自动执行该脚本
这是对OOM的运维处理,一般针对jar包的启动脚本中添加JVM参数-XX:OnOutOfMemoryError=/opt/Server/restart.sh,比如在发生OOM的时候执行shell脚本让服务重启
[linux下restart.sh示例]
xxxxxxxxxxpid=$(ps -ef|grep Server.jar|awk '{if($8=="java"){print $2}}')kill -9 $pidcd /opt/Server/;sh run.sh[Windows下restart.sh示例]
xxxxxxxxxxecho offwmic process where Name='java.exe' deletecd D:\Serverstart run.bat垃圾收集器相关
-XX:+PrintCommandLineFlags:查看命令行相关参数[结果的最后会显示使用了哪种垃圾收集器]
使用jinfo -flag 指定垃圾收集器参数 <PID>也能查看指定垃圾收集器是否正在被使用
Serial
Client模式下的默认新生代垃圾收集器,Serial Old是Client模式下默认的老年代垃圾收集器,可以获取最高的单线程垃圾收集效率,32位操作系统CPU核数大于等于2,内存大于两个G,建议切换成Server模式;作为单核场景下的垃圾收集器,用户线程和垃圾收集线程肯定不能并发工作,像Web这种服务端客户端交互性很强的场景只能使用并行或者并发垃圾收集器,不能使用Serial
-XX:+UseSerialGC:指定新生代和老年代都使用串行收集器
ParNew
ParNew和Serial Old搭配在JDK8过时,在JDK9彻底废弃了该搭配;即JDK8以后,ParNew就只能和CMS搭配使用,Serial Old作为CMS的兜底垃圾收集器;但是CMS又在JDK14被移除,ParNew的位置就比较尴尬
-XX:+UseParNewGC:手动指定新生代使用ParNew并行垃圾收集器,不影响老年代
-XX:ParallelGCThreads=N:设置年轻代并行垃圾收集线程的数量,当CPU数量小于等于8个,默认开启和CPU数量相同的垃圾收集线程;当CPU数量大于8个,ParallelGCThreads的默认值为(5*CPU核数)/8向下取整后加3;
Parallel
JDK8的默认垃圾收集器,侧重于吞吐量,服务器端注重高并发和整体的吞吐量,服务器端适合使用Parallel进行垃圾收集
-XX:+UseParallelGC:手动指定年轻代使用Parallel并行垃圾收集器,开启该JVM参数会自动激活-XX:+UseParallelOldGC
-XX:+UseParallelOldGC:手动指定老年代使用parallelOld并行垃圾收集器,开启该JVM参数会自动激活-XX:+UseParallelGC
-XX:ParallelGCThreads=N:设置年轻代并行垃圾收集线程的数量,当CPU数量小于等于8个,默认开启和CPU数量相同的垃圾收集线程;当CPU数量大于8个,ParallelGCThreads的默认值为(5*CPU核数)/8向下取整后加3
-XX:MaxGCPauseMillis:设置垃圾收集器最大暂停时间STW,单位是毫秒,
为了尽可能将暂停时间控制在该参数指定的时间内,收集器工作时会自动调整Java堆的大小和其他一些JVM参数,能自动调整堆大小是因为开启了-XX:+UseAdaptiveSizePolicy自适应堆大小调节策略
对于客户端用户追求低延迟能带来更好的响应;因为parallel主打吞吐量,不建议对parallel的暂停时间要求太苛刻
-XX:+UseAdaptiveSizePolicy:开启JVM的自适应堆大小调节策略
启用该配置后,新生代的大小、伊甸园区和幸存者区的比例、晋升老年代的对象年龄等参数都会由JVM自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点
一般开启自适应调节策略的情况下仅指定JVM的堆最大大小、垃圾收集器的目标吞吐量和最大停顿时间,其他参数让JVM自动调整完成调优工作
老师在最后一节课又说实际开发中不建议开启-XX:+UseAdaptiveSizePolicy,但是没讲为什么
-XX:GCTimeRatio:设置垃圾收集时间占总时间的比重[1/(N+1)],即设置垃圾收集器的吞吐量
N的取值范围为(0,100),默认值为99,即垃圾收集时间不超过1%,当-XX:maxGCPauseMillis参数设置的越长,垃圾收集器收集时间占总时间的比例就越容易超出预设的比例
垃圾收集时间越短,系统的吞吐量就越大
CMS
CMS在JDK1.5引入,是HotSpot虚拟机中第一款真正意义上实现了并发的垃圾收集器,并发即垃圾收集线程和用户线程可以同时运行,CMS只能和Serial或者ParNew搭配[因为底层架构的问题无法和parallel搭配],Serial又只适用于硬件性能比较差的场景,很难适配现代服务端的高性能硬件场景,几乎只能和ParNew搭配,基于标记清除算法,在JDK9中被标记为废弃[可以通过-XX:+UseConcMarkSweepGC来启用,但是用户会收到一个CMS将在未来被移除的警告],在JDK14被移除[此时如果还通过-XX:+UseConcMarkSweepGC来启用CMS,JVM会给出警告自动以默认的垃圾收集器启动JVM不会导致JVM无法启动],但是目前在服务器与用户强交互的场景下还是非常常见
-XX:+UseConcMarkSweepGC:手动指定使用CMS收集器执行内存回收任务,开启该参数会自动启动-XX:+UseParNewGC,自动使用ParNew+CMS+Serial Old组合
-XX:CMSInitiatingOccupanyFraction:设置垃圾收集器开始垃圾回收的堆内存使用率阈值
JDK5及以前版本的默认值为68,即老年代的空间使用率达到68%时执行一次CMS垃圾回收,JDK6及以后的版本默认值为92%,内存增长缓慢可以将阈值设置得稍大来降低CMS的触发频率减少老年代垃圾回收次数明显改善应用程序性能;应用程序内存使用率增长很快应该降低该阈值避免CMS垃圾收集速度跟不上用户的内存消耗速度,避免频繁触发老年代串行垃圾收集器的Full GC次数
-XX:+UseCMSCompactAtFullCollection:CMS基于标记清除算法会产生内存碎片问题,配置该参数指定在执行完一次Full GC后对内存空间进行压缩整理避免因为内存碎片导致频繁的Full GC,因为单个老年代内存压缩整理过程无法并发执行,因此Full GC的停顿时间会更长
-XX:CMSFullGCBeforeCompaction:设置在执行多少次Full GC后对内存空间进行压缩整理
-XX:parallelCMSThreads:设置CMS垃圾收集的线程数量,默认启动的CMS垃圾线程数是(ParallelGCThreads+3)/4[ParallelGCThreads是年轻代并行垃圾收集器的垃圾收集线程数],如果设置了-XX:parallelCMSThreads以该参数设置为主,受CMS收集线程的影响,当CPU资源紧张时,应用程序的性能在垃圾收集阶段可能非常糟糕
-XX:ConcGCThreads:设置并发垃圾收集的线程数,默认值基于ParallelGCThreads参数计算得到
-XX:+UseCMSInitiatingOccupancyOnly:启用该参数可以使CMS一直按CMSInitiatingOccupanyFraction设置的老年代内存阈值进行垃圾收集二不会自动动态调整
-XX:+CMSScavengeBeforeRemark:强制HotSpot虚拟机在CMS remark阶段前做一次Minor GC用于提高remark阶段的速度
-XX:+CMSClassUnloadingEnable:启用该参数会允许CMS垃圾收集器在执行垃圾收集时卸载方法区未被引用的类,该参数的启用可以在类加载器频繁加载和卸载类的场景中优化内存的使用
-XX:+CMSParallelInitialEnabled:启用CMS初始标记阶段多线程并行标记可触及对象,提高标记速度,JDK8默认是开启的
-XX:+CMSParallelRemarkEnabled:启用CMS重新标记阶段多线程并行标记可触及对象,默认是开启的
-XX:+ExplicitGCInvokesConcurrent、-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses:这两个JVM参数指定HotSpot虚拟机在执行System.gc()时使用CMS垃圾收集器,而不是默认的Full GC,可以显著减少垃圾收集带来的停顿时间
比如使用NIO框架Netty时,堆外内存的回收需要Full GC完成,通过启用改参数可以使用CMS垃圾收集器回收堆外内存,避免因为Full GC带来的长时间停顿
该参数还会再并发垃圾收集的过程中卸载未被引用的类,在类加载器频繁加载和卸载类的场景中优化内存的使用
-XX:+CMSPrecleaningEnabled:启用时CMS垃圾收集器会在并发标记阶段前执行一次预清理提前清理掉一些容易识别的垃圾对象从而减少并发标记阶段的工作量,尤其在老年代中存在大量短生命周期对象的场景中可以提高垃圾收集的效率,减少后续并发标记阶段和清理阶段的停顿时间
G1:
基于硬件水平提升CPU核数越来越多、内存越来越大,互联网项目高并发场景越来越多与用户交互越来越频繁,推出了主打低延迟的G1垃圾收集器;G1收集器的Mixed GC会同时回收新生代和部分老年代
建议使用G1就不要使用-Xmn和-XX:NewRatio设置年轻的最大内存和新生代与老年代比例的大小,设置了会影响暂停时间的表现
-XX:+UseG1GC:手动指定使用G1垃圾收集器执行内存回收任务
-XX:G1HeapRegionSize:设置每个Region的大小,值为范围在1-32MB之间的二次幂,目标是将Java堆划分出约2048个区域,默认为堆内存的1/2000
-XX:MaxGCPauseMillis:设置期望的最大GC暂停时间,JVM会尽力实现GC最大暂停时间,但是不保证一定实现,默认是200ms
-XX:ParallelGCThread:设置STW时执行并行垃圾收集的线程数
-XX:ConcGCThreads:设置执行并发标记的线程数,一般将线程数设置为并行垃圾收集线程数-XX:ParallelGCThread的1/4左右
-XX:InitiatingHeapOccupancyPercent:设置触发G1并发垃圾收集周期的Java堆占用率阈值,默认值为45
-XX:G1NewSizePercent、-XX:G1MaxNewSizePercent:设置新生代占整个堆内存的最小百分比[默认5%]和最大百分比[默认60%]
-XX:G1ReservePercent=10:该参数设置堆内存保留指定大小的一部分空间作为假天花板,预留出的空间用于减少新生代对象晋升到老年代时因为空间不足导致的Full GC,默认情况下老年代预留10%的空间给新生代对象晋升
G1的Mixed GC调优常用参数:一般会根据dump文件和GC日志文件做相应的调整
-XX:InitiatingHeapOccupancyPercent:设置触发G1全局并发标记的堆占用率阈值,默认值为45%,可选值为0-100,值为0表示间断进行全局并发标记
-XX:G1MixedGCLiveThresholdPercent:设置老年代的region允许被回收时region中的对象占比阈值,默认占用率为85%,只有老年代的region中存活的对象占用达到这个百分比才会在Mixed GC中被回收
-XX:G1HeapWastePercent:在每次YGC和Mixed GC前,会检查可回收垃圾占整个堆内存的比例是否低于该阈值,低于该阈值本轮混合回收即使没有达到8次也会停止
老年代region默认情况下会被分8次被回收,优先回收回收价值最高的region,回收次数可以通过JVM参数-XX:G1MixedGCCountTarget设置,而且只有region才会被回收,混合回收的回收集中包含1/8的未被回收的老年代region,全部的新生代region,采用和年轻代回收一样的流程进行垃圾回收,只是多了回收已经被标记存活对象的老年代region,混合回收不一定必须要进行8次,如果JVM发现可以回收的垃圾占堆内存的比例低于阈值10%,就会停止混合回收,该阈值可以通过JVM参数-XX:G1HeapWastePercent设置,默认值是10,意思是允许整个堆内存有10%的空间被浪费,避免花费很多的时间进行GC但是回收的内存却很有限
-XX:G1MixedGCCountTarget:设置一次全局并发标记后,Mixed GC执行的最大次数,默认为8
-XX:G1OldCSetRegionThresholdPercent:设置一个Mixed GC垃圾收集周期中要收集的老年代region数的上限,默认值是java堆的10%
垃圾收集器的选择策略
优先调整堆的大小让JVM自适应调整
内存小于100M使用串行垃圾收集器
单核单机程序没有停顿时间要求使用串行垃圾收集器
多CPU、需要高吞吐量、允许停顿时间超过1s,选择并行垃圾收集器或者让JVM自动选择
多CPU、追求低暂停时间、互联网应用响应延迟不超过1s,使用并发垃圾收集器,官方推荐G1,现在的互联网项目基本都使用G1
GC日志相关参数
常用参数:
-verbose:gc:标准参数类型,输出简化的GC日志信息,等价于-XX:+PrintGC,打印的内容都是相同的
-XX:+PrintGC:等同于-verbose:gc,输出简化的GC日志信息
-XX:+PrintGCDetails:发生GC时打印垃圾收集的详细日志,进程退出时输出当前个内存区域的详细信息,这个GC日志比-XX:+PrintGC更详细,当同时配置参数-XX:+PrintGCDetails和-XX:+PrintGC时-XX:+PrintGC参数会失效
-XX:+PrintGCTimeStamps:输出GC发生时的时间戳,该参数不能独立使用,JVM正常运行但是不会打印任何结果,一般要搭配-XX:+PrintGCDetails一起使用,在日志头部打印JVM启动到GC发生时刻的时间长度,单位是s
-XX:+PrintGCDateStamps:输出GC发生时的日期形式时间戳[如2013-05-04T21:53:59.234+0800],该参数不能独立使用,JVM正常运行但是不会打印任何结果,一般要搭配-XX:+PrintGCDetails一起使用
-XX:+PrintHeapAtGC:每次GC前后都打印堆内存信息,该参数可以独立使用,也可以和-XX:+PrintGCDetails一起使用,此时会将堆内存信息和GC信息混合打印,并在JVM进程结束以前打印一次堆内存信息
-Xloggc:<file>:将GC日志写入到一个文件中而不是打印到默认的控制台[标准输出],比如-Xloggc:d:/heaplog.log
其他参数
-XX:+TraceClassLoading:监控类的加载信息
-XX:+PrintGCApplicationStoppedTime:打印每次GC线程的暂停时间
-XX:+PrintGCApplicationConcurrentTime:在垃圾收集前打印出应用连续运行的时间
-XX:+PrintReferenceGC:启用时会打印GC过程中与引用处理相关的垃圾回收信息,会显示回收的软引用、弱引用和虚引用的数量
-XX:+PrintTenuringDistribution:JVM在每次minorGC后都会打印出当前使用的幸存者区中对象的年龄分布
-XX:+UseGCLogFileRotation:启用GC日志文件的滚动功能,即GC日志文件达到指定大小时JVM会将日志内容输出到下一个日志文件,避免单个日志文件过大
-XX:NumberOfGClogFiles=1:该参数通常与-XX:+UseGCLogFileRotation一同使用,用于设置滚动日志文件的数量,默认值为0,表示不滚动
-XX:GCLogFileSize=1M:设置每个GC日志文件的最大大小,日志文件达到指定大小后,日志内容会自动滚动到下一个日志文件
其他参数
-XX:+DisableExplictGC:禁止HotSpot执行显示调用System.gc()导致的GC操作,默认情况下是开启的,让JVM的垃圾回收机制完全由自身的策略进行控制
-XX:ReservedCodeCacheSize=<n>[g|m|k]、-XX:InitialCodeCacheSize=<n>[g|m|k]:分别表示设置即时编译器生成的本地机器代码缓存区域的预留空间大小以及初始空间大小,在较新的JVM中代码缓存区域空间大小的默认值通常为240MB,初始大小为160KB
-XX:+UseCodeCacheFlushing:当代码缓存区域满了以后,JVM会关闭即时编译器切换到纯解释执行模式,启用该参数后,代码缓存区域满了以后会自动清除代码缓存中的部分缓存为新的即时编译任务腾出空间
在某些情况下代码缓存被填满可能会导致JVM抛出java.lang.OutOfMemoryError:CodeCache is full异常,启用该参数可以通过释放一些空间来缓解这种问题
-XX:+DoEscapeAnalysis:开启逃逸分析
-XX:+UseBiasedLocking:开启偏向锁
-XX:+UseLargePages:开启使用大页面
-XX:+UseTLAB:使用TLAB,默认开启
-XX:+PrintTLAB:打印TLAB的打印情况
-XX:TLABSize:设置TLAB的大小
通过Java代码获取JVM参数
方式一:通过Runtime获取
long initialMemory =Runtime.getRuntime().totalMemory()、
long maxMemory=Runtime.getRuntime().maxMemory()
Runtime类还可以获取一些内存、CPU核数相关的数据
方式二:java.lang.management包用于本地或者远程监视和管理java虚拟机和其他组件,其中的ManagementFactory比较常用
xxxxxxxxxx/** * @描述 * 打印结果 * INIT HEAP: 508m * MAX HEAP: 7225m * USE HEAP: 22m * * Full Information: * Heap Memory Usage: init = 532676608(520192K) used = 23970824(23409K) committed = 510656512(498688K) max = 7575961600(7398400K) * Non-Heap Memory Usage: init = 2555904(2496K) used = 9692424(9465K) committed = 10027008(9792K) max = -1(-1K) * 当前堆内存大小: 487m * 空闲堆内存大小: 464m * 最大可用堆内存大小: 7225m * @author Earl * @version 1.0.0 * @创建日期 2025/04/22 * @since 1.0.0 */public void testManagementFactory(){ MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean(); MemoryUsage usage = memoryMXBean.getHeapMemoryUsage(); System.out.println("INIT HEAP: "+usage.getInit()/1024/1024+"m"); System.out.println("MAX HEAP: "+usage.getMax()/1024/1024+"m"); System.out.println("USE HEAP: "+usage.getUsed()/1024/1024+"m"); System.out.println("\nFull Information:"); System.out.println("Heap Memory Usage: "+ memoryMXBean.getHeapMemoryUsage()); System.out.println("Non-Heap Memory Usage: "+ memoryMXBean.getNonHeapMemoryUsage());
//通过Java代码获取系统信息 System.out.println("当前堆内存大小: "+(int) Runtime.getRuntime().totalMemory()/1024/1024+"m"); System.out.println("空闲堆内存大小: "+(int) Runtime.getRuntime().freeMemory()/1024/1024+"m"); System.out.println("最大可用堆内存大小: "+Runtime.getRuntime().maxMemory()/1024/1024+"m");}GC日志分析
GC相关参数
-verbose:gc:标准参数类型,输出简化的GC日志信息,等价于-XX:+PrintGC,打印的内容都是相同的
-XX:+PrintGC:等同于-verbose:gc,输出简化的GC日志信息
-XX:+PrintGCDetails:发生GC时打印垃圾收集的详细日志,进程退出时输出当前个内存区域的详细信息,这个GC日志比-XX:+PrintGC更详细,当同时配置参数-XX:+PrintGCDetails和-XX:+PrintGC时-XX:+PrintGC参数会失效
该参数能打印当前新生代和老年代使用的是什么垃圾收集器
-XX:+PrintGCTimeStamps:输出GC发生时的时间戳,该参数不能独立使用,JVM正常运行但是不会打印任何结果,一般要搭配-XX:+PrintGCDetails一起使用,在日志头部打印JVM启动到GC发生时刻的时间长度,单位是s
-XX:+PrintGCDateStamps:输出GC发生时的日期形式时间戳[如2013-05-04T21:53:59.234+0800],该参数不能独立使用,JVM正常运行但是不会打印任何结果,一般要搭配-XX:+PrintGCDetails一起使用
-XX:+PrintHeapAtGC:每次GC前后都打印堆内存信息,该参数可以独立使用,也可以和-XX:+PrintGCDetails一起使用,此时会将堆内存信息和GC信息混合打印,并在JVM进程结束以前打印一次堆内存信息
-Xloggc:<file>:将GC日志写入到一个文件中而不是打印到默认的控制台[标准输出],比如-Xloggc:d:/heaplog.log
-XX:+TraceClassLoading:监控类的加载信息
-XX:+PrintGCApplicationStoppedTime:打印每次GC线程的暂停时间
-XX:+PrintGCApplicationConcurrentTime:在垃圾收集前打印出应用连续运行的时间
-XX:+PrintReferenceGC:启用时会打印GC过程中与引用处理相关的垃圾回收信息,会显示回收的软引用、弱引用和虚引用的数量
-XX:+PrintTenuringDistribution:JVM在每次minorGC后都会打印出当前使用的幸存者区中对象的年龄分布
-XX:+UseGCLogFileRotation:启用GC日志文件的滚动功能,即GC日志文件达到指定大小时JVM会将日志内容输出到下一个日志文件,避免单个日志文件过大
-XX:NumberOfGClogFiles=1:该参数通常与-XX:+UseGCLogFileRotation一同使用,用于设置滚动日志文件的数量,默认值为0,表示不滚动
-XX:GCLogFileSize=1M:设置每个GC日志文件的最大大小,日志文件达到指定大小后,日志内容会自动滚动到下一个日志文件
GC分类[垃圾回收分类]
GC按照回收区域的分类分为部分收集Partial GC和整堆收集Full GC
部分收集
新生代垃圾收集[Minor GC|YGC]:频繁速度快
老年代垃圾收集[Major GC|Old GC]:目前只有CMS会有单独收集老年代的行为,通常对象在YGC以后仍然不能存放在伊甸园区才会放在老年代,老年代放不下才会执行Major GC,因此Major前都会有一次Minor GC
混合垃圾收集[Mixed GC]:收集整个新生代以及部分老年代
目前只有G1有混合垃圾收集行为
整堆收集[Full GC]:收集整个Java堆和方法区,JVM规范在逻辑上将方法区也算在堆里面,落地的时候设置JVM参数时堆只考虑新生代和老年代
触发Full GC的几种情况:
调用System.gc()时会建议系统执行FullGC,系统会根据运行情况自行判断是否执行Full GC
老年代或者方法区空间不足
从YGC晋升老年代的对象平均大小大于老年代的可用内存
大对象直接进入老年代但是老年代的可用空间不足
导致G1进行Full GC的原因:
YGC前老年代的可用连续空间小于前几次年轻代晋升老年代的平均大小会直接将YGC替换成Full GC,方法区空间满了时[概率小,因为方法区使用的是本地内存,空间很大]
调用System.gc()时会建议系统执行FullGC,系统会根据运行情况自行判断是否执行Full GC
混合回收完成前老年代的空闲空间已经被耗尽,此时就会使用Full GC暂停所有用户线程来进行兜底垃圾收集[比如暂停时间设置的太短,回收频率变高,但是如果垃圾回收的速度跟不上垃圾产生的速度内存最终还是会被耗尽并触发Full GC]
GC日志的解析
注意G1的日志和其他垃圾收集器的日志变化比较大,后面再专门总结
-XX:+PrintGCDetails:打印详细的GC处理日志和堆空间内存情况,不同的GC打印信息不同,参数优先级比下面一个参数更高,显示的内容依次为
如果配置了JVM参数-XX:+PrintGCDateStamps日志的打头为2013-05-04T21:53:59.234+0800即GC发生的时刻;如果配置的是JVM参数-XX:+PrintGCDateStamps日志的打头为JVM启动到GC发生时刻的以秒为单位的时间
GC类型[Minor GC会显示为GC,Full GC会显示为FullGC]、GC原因[Minor GC一般都是Allocation Failure表示没有足够的内存创建新的对象;Full GC的原因可能有Metadata GC Threshold元空间内存不够用,Ergonomics新生代对象或者大对象晋升老年代内存空间不足,System手动调用System.gc()方法]
元空间内存不足导致的Full GC可能最终的结果是老年代内存因为新生代对象晋升反而变大了,因此Full GC后看到老年代占用内存反而增大了不要慌
新生代使用的垃圾收集器[一般不同的垃圾收集器显示不同的新生代名字,我们可以根据名字的区别判断对应区域垃圾收集使用的哪种垃圾收集器]和新生代占用内存垃圾收集前后变化以及新生代的总容量[如[PSYoungGen:76800K->8433K(89600K)]箭头前面是GC前的新生代内存占用、箭头后面是GC后的新生代内存占用、括号内是新生代总容量,注意该总容量是伊甸园区加一个幸存者区,是整个新生代的9/10];以上为Minor GC的日志内容,Full GC还会额外打印老年代使用GC和老年代占用内存GC前后变化以及老年代的总容量[格式与新生代GC日志格式是一样的]
Serial显示新生代名字为[DefNew;ParNew显示新生代名字为[ParNew;Parallel显示新生代名字为[PSYoungGen;Parallel Old显示老年代的名字为[ParOldGen;G1显示garbage-first heap
整个堆占用内存GC前后变化和堆内存总容量[GC前堆内存已使用容量->GC后堆内存已使用容量(堆内存总容量),其中堆内存总容量为十分之九的新生代+老年代]
以上为Minor GC的日志内容,Full GC日志还会额外打印方法区占用内存GC前后变化
GC过程以秒为单位的持续时间;Times中user是垃圾收集器花费的所有CPU时间[CPU工作在用户态花费的时间,不包含其他进程的执行时间即垃圾回收线程阻塞的时间]、sys是花费在等待系统调用或者系统事件的时间[CPU工作在内核态花费的时间]、real是从GC开始到GC结束的整个持续时间[GC开始到结束花费的现实时间,包含和其他线程抢夺CPU时间片以及等待的时间,一般real会小于user+sys,这是因为现在一般使用的都是多核CPU,如果real>user+sys说明IO负载比较重或者CPU核数不够用]
堆信息依次显示新生代def new generation;老年代tenured generation;永久代compacting perm gen;元空间MetaSpace的最大可用内存和已使用的内存,还会显示每个区域的虚拟内存开始和结束的地址
GC日志分析工具
开发者需要使用上述GC日志数据计算GC的吞吐量、暂停时间等数据,GC日志一多自己计算这些数据是不可能的,一般使用GC日志分析工具来对GC数据进行统计和辅助分析,GCEasy相对来说是比较好用的GC日志分析工具
在线GC日志分析网站,官网:https://gceasy.io/,可以通过GC日志分析进行内存泄漏检测、GC暂停原因分析、JVM配置建议优化,有些功能是收费的
GCEasy的分析数据
JVM Memory Size
JVM内存各区域大小
发生OOM时各区域内存大小
Key Performance Indicators
Thoughput吞吐量
Latency延迟情况/暂停时间[平均暂停时间和最大暂停时间,一定范围内的暂停时间占总样本的百分比]
Interactive Graphs
GC前后的堆内存占用情况[红色三角就是GC时间点,横轴是时间]
GC的持续时间
新生代、老年代、元空间每次GC前后的总内存大小、GC前后占用内存大小
GC Statistics垃圾收集统计数据
GCViewer
介绍:Oracle免费开源离线版的GC日志分析工具,用于可视化查看SUN/Oracle、IBM、HP、BEA的JVM产生的GC日志,用于可视化JVM参数-verbose:gc和-Xloggc:<file>生成的gc日志,统计垃圾回收的吞吐量、累计暂停时间、最长暂停时间
下载
源码:https://github.com/chewiebug/GCViewer
运行版本:https://github.com/chewiebug/GCViewer/wiki/Changelog
运行
下载的是jar包,双击gcviewer-1.3x.jar或者使用命令java -jar gcviewwe-1.3x.jar启动,需要安装适配对应版本的JDK
GCViewer的数据解析很有限,除了Event details中展示部分统计数据,主要的信息展示在Summary、Memory、Pause面板中
GChisto
介绍:gc日志分析工具,分析GC日志数据通过图表、报表、列表等不同形式展示GC次数、频率、持续时间
下载需要从SVN拉取并进行编译,而且不怎么维护存在很多bug,界面也很粗糙
HPjmeter
介绍:只能打开由JVM参数-verbose:gc和-Xloggc:gc.log生成的日志文件,只要添加了其他参数生成的GC日志文件就无法被HPjmeter打开,HpJmeter集成了HPjtune功能,功能比较强大,一般用来分析在HP机器上产生的GC日志文件
OOM常见应用场景和解决方案[课程在尚硅谷官网大厂学院里面作为JVM的第七章,具体看宋红康JVM最后一节,统一在JVM与GC调优章节,300块钱]
堆溢出
元空间溢出
GC overhead limit exceeded
线程溢出
性能优化思路[课程在尚硅谷官网大厂学院里面作为JVM的第八章,具体看宋红康JVM最后一节,统一在JVM与GC调优章节,300块钱]
JMeter压测
调整堆大小、垃圾收集器、提高服务吞吐量
JIT优化
调整G1并发垃圾收集线程数
调整新生代和老年代比例
CPU占用很高问题如何排查
日均百万级别的订单交易系统如何设置JVM参数
JVM有哪些性能调优方法、调优参数、调优命令和调优工具
常用调优工具
jvisualvm
jconsole
jprofiler
GCViewer
GC Easy
Java Flight Recorder:JMC中的Java飞行记录仪,可以实时对JVM内存空间进行监控
Eclipse:Memory Analyzer Tool:MAT可以对jMap导出的文件进行离线分析
JDK命令行指令如jinfo、jstat、javap、jMap
本来只想简单点只总结面试要点,没忍住又肝了一遍,导致笔记分散在两个文档中,另一个内容较少的文档拷贝到该文档的附录中,后面复习的时候再将附录中的另一个文档内容合并整理到正文中
软件jclasslib和Binary Viewer能查看字节码中的字节数据,将字节码文件拖入Binary Viewer能看到对应字节码,字节码文件的模数是16进制的CAFEBABE,一共占用四个字节;jclasslib能看到字节码文件字节编译为字符后的字节码文件结构
IDEA的class文件本身就是被反编译以后的文件,javap指令是解析字节码的指令[要解析出私有变量需要加参数-p],javap -v -p Order.class > test.txt是将反解析后的结果写入到test.txt文件中
成员变量中被static修饰的称为静态变量也叫类变量,没有被static修饰的称为实例变量
辨析类变量、局部变量和实例变量:在链接的准备阶段在方法区为类变量开辟空间并赋默认值,在初始化阶段为类变量显示赋值;局部变量在使用前必须先进行显示赋值,否则编译不通过,局部变量不会自动赋值默认值;实例变量随着对象的创建在堆区分配空间并赋值默认值;
据说腾讯的虚拟机是Kona JDK
IO数据传输使用的基础工具是字节数组或者字符数组,IO是基于流Stream,NIO基于Buffer
Java是半编译型半解释型语言
语言的发展历史
机器码:二进制编码表示的指令。机器指令可以被计算机理解接受,但是不易于人们理解记忆[人们把业务翻译成数据的计算过程编写成CPU按一定顺序数据处理的指令],人不方便理解和记忆,编程容易出错;机器语言输入计算机,CPU直接读取运行,执行速度非常快,机器指令和CPU紧密相关,不同种类的CPU对应的机器指令差别非常大
指令:用英文简写的指令比如mov、inc等替代二进制机器指令,增强机器码的可读性,人们不用再记忆某个操作的二进制机器码;不同硬件平台执行同一个操作的机器码可能不同,因此同一个指令mov在不同硬件平台上的机器码也可能不同
指令集:每个平台支持的所有指令成为对应平台的指令集,比如X86架构平台的x86指令集,ARM架构的ARM指令集
汇编语言:汇编语言用助记符代替机器指令的操作码,用地址符号或者标号代替指令或者操作数的地址,不同的硬件平台,汇编语言对应不同的机器语言指令集,汇编语言通过汇编过程转成机器指令,汇编语言编写的程序需要翻译成机器指令码才能被计算机识别和执行
高级语言:高级语言更接近人的语言,高级语言也需要解释编译成机器指令才能被计算机识别执行,完成该解释编译过程的程序叫做解释程序或者编译程序,解释执行就对应指令流被解释器逐条解释执行,编译执行程序将指令生成对应的机器指令
高级语言通过编译过程先生成汇编语言,汇编语言通过汇编过程生成机器指令再交给CPU执行,这实际上是C和C++的源码处理环节;编译过程可以分为编译和汇编两个阶段,编译过程是读取字符流源程序,对源程序进行词法和语法分析,将高级语言指令转换成功能等效的汇编代码;汇编构成是将汇编语言翻译成机器指令
字节码是一种中间码状态的二进制代码文件,在Java源程序和汇编语言之间为了跨平台特性引入了字节码指令,通过前端编译器实现跨语言,通过不同平台的虚拟机对同一份字节码文件转译为对应平台上的可直接执行的指令来实现跨平台
字节码的典型应用为Java bytecode
方法中也可以像类一样定义非静态代码块
Java不同版本新特性的学习角度
语法层面:Lambda表达式、switch表达式、自动装箱拆箱、Enum、泛型...
API层面:新API的引入[Stream API、新的日期API、Optional类、集合],旧API的移除,API的更新[比如String的value属性]...
底层优化:JVM优化、GC变化、对新语言的支持、元空间和串池的变化...
32位操作系统可以选择使用Client或者Server模式,通过java -version可以查看JVM的工作模式
十种排序方式
IOS底层硬件、操作系统、开发语言都是Apple自己家的,兼容性非常好、也无需考虑扩展性,软件设计不需要过多的接口,系统流畅性就非常好;安卓的CPU等硬件生态涉及到各种各样的厂商、接口设计特别冗余、主要是这个原因导致的卡顿
JSR[Java Specification Request]:Java规范提案;JEP[Java Enhancement Proposal]:Java增强提议;JCP[Java Community Process]:Java社区进程,管理和维护Java技术规范的开放性国际组织,制定Java平台的各种技术规范,管理规范提案JSR
《新一代垃圾回收器ZGC设计与实现》
JVM规范网址:https://docs.oracle.com/javase/specs/index.html
弹幕推荐了一个IDEA插件Binary/hexadecimal
软件Beyond Compare可以比较两个文件的区别,自动发现两个文件不同的部分
string.concat(str2)是将字符串str2拼接在字符串string的末尾
zoomit画笔软件
通过不同实例对象获取引用类型静态变量始终获取的是同一个对象,程序运行中通过静态变量关联的对象不会被回收,比如手动对静态变量中的引用置空对应的对象才会被回收,这在开发中要非常注意,这会导致内存泄漏
静态变量不会随着实例的销毁而销毁,会与class对象的生命周期保持一致,一般会与JVM的生命周期保持一致
实例变量会随着实例的销毁而销毁
IDEA中的DEBUG功能据说也使用了javaAgent技术
面试要点内存结构、GC算法、垃圾回收器、GC的JVM参数、字节码文件结构、字节码指令、类加载过程、类加载器种类、命令行调优工具、GUI调优工具、常见JVM参数、GC日志分析
BATJTMDPKQ分别对应的公司为百度、阿里、腾讯、京东、头条、美团、滴滴、拼多多、快手、趣头条
JVM规范在oracle官网可以下载,文件名字为Java Language and Virtual Machine Specifications,前者为语言的规范,后者为虚拟机的规范,都是英文的,中文可以参考《Java虚拟机规范》[这本书就像是官方规范的翻译,一般用来查阅]、对JVM的理解和使用首推《深入理解Java虚拟机-周志明》、《自己动手写Java虚拟机》[这个Java虚拟机是用GO语言实现的,GO语言本身就有比较完善的垃圾回收机制,用C语言因为内存完全暴露给程序员会比较崩溃]
互联网基于JS、人工智能基于Python、微服务基于Go
信息产业有三大技术难题:CPU、操作系统、编译器
自己编写Java虚拟机要实现完整的规范甚至能达到商用的性能和稳定性非常难,如果只是学习底层的原理跑个程序就不难
公司开发习惯用Mac,Mac最突出的地方是硬件[CPU结构和架构设计]、操作系统、常用软件都是由Apple公司自己设计完成的,耦合度很高,因此整体性能非常好,一般大公司都会配备Mac,Mac上面最有价值的就是那个操作系统,没必要在Mac上再装一个Windows
弹幕提示IDEA可以使用jclasslib插件查看源码对应的字节码指令,在字节码文件对应目录下使用Java命令javap -v 字节码文件名.class,执行结果中的Code中的第一个stack=...下的内容就是对应方法名的方法体的字节码指令
IDEA中Java源码可以通过Build--Recompile 文件名重新编译Java源码
javap -v 字节码文件名.class是对字节码文件进行反汇编,方便查看字节码文件中的信息
[javap命令执行结果]
xxxxxxxxxxEarl@Earl MINGW64 /d/maven-space/mall/renren-generator/target/classes/io/renren/adaptor (master)$ javap -v HelloWorld.classClassfile /D:/maven-space/mall/renren-generator/target/classes/io/renren/adaptor/HelloWorld.class Last modified 2025-2-17; size 463 bytes MD5 checksum 50a61ade8c63b9462a666d4c2bb91357 Compiled from "HelloWorld.java"public class io.renren.adaptor.HelloWorld minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER#Constant pool就是该字节码文件需要用到的常量池中的数据Constant pool: #1 = Methodref #3.#20 // java/lang/Object."<init>":()V #2 = Class #21 // io/renren/adaptor/HelloWorld #3 = Class #22 // java/lang/Object #4 = Utf8 <init> #5 = Utf8 ()V #6 = Utf8 Code #7 = Utf8 LineNumberTable #8 = Utf8 LocalVariableTable #9 = Utf8 this #10 = Utf8 Lio/renren/adaptor/HelloWorld; #11 = Utf8 main #12 = Utf8 ([Ljava/lang/String;)V #13 = Utf8 args #14 = Utf8 [Ljava/lang/String; #15 = Utf8 i #16 = Utf8 I #17 = Utf8 MethodParameters #18 = Utf8 SourceFile #19 = Utf8 HelloWorld.java #20 = NameAndType #4:#5 // "<init>":()V #21 = Utf8 io/renren/adaptor/HelloWorld #22 = Utf8 java/lang/Object{ public io.renren.adaptor.HelloWorld(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lio/renren/adaptor/HelloWorld;
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=2, args_size=1 #这个带序号的就是对应方法的字节码 0: iconst_5 1: istore_1 2: return LineNumberTable: line 5: 0 line 6: 2 LocalVariableTable: Start Length Slot Name Signature 0 3 0 args [Ljava/lang/String; 2 1 1 i I MethodParameters: Name Flags args}SourceFile: "HelloWorld.java"[int i = 2 + 3;字节码示例]
xxxxxxxxxxCode: stack=1, locals=2, args_size=1 #这个带序号的就是对应方法的字节码 0: iconst_5 1: istore_1 2: return[int i=2;int j=3;int k=i+j;字节码示例]
xxxxxxxxxxCode: stack=2, locals=4, args_size=1 0: iconst_2 #定义一个常量2 1: istore_1 #将常量保存在索引为1的操作数栈中 2: iconst_3 #定义一个常量3 3: istore_2 #将常量保存在索引为2的操作数栈中 4: iload_1 #加载操作数栈索引为1的数据 5: iload_2 6: iadd #将加载的两个数据相加 7: istore_3 #求和后的结果保存在索引为3的操作数栈中 8: returnIOS系统的流畅度是因为从硬件架构到系统到应用的编程语言都是Apple自己的,整体高度耦合,共同作用实现的高流畅度;因此单纯用其他牌子的手机安装IOS系统是不能提高手机的流畅度的
JVM概述
概念:
虚拟机:
虚拟机就是一台模拟计算机的软件[计算机硬件上面一层是操作系统、操作系统上面一层是软件],用来执行一系列虚拟计算机指令,虚拟机可以分为系统虚拟机和程序虚拟机
系统虚拟机如Virtual Box、VMware是对物理计算机的仿真,提供可运行完整操作系统的软件平台
程序虚拟机如JVM,专门设计来执行单个计算机程序,在Java虚拟机中执行的指令称为Java字节码指令,Java虚拟机是解释运行Java字节码的虚拟计算机,任何语言编译后形成的字节码文件只要遵循JVM规范中的要求就可以被JVM解释运行,JVM平台的各种语言都可以共享JVM带来的跨平台性、垃圾回收器和JIT即时编译器
JVM负责装载二进制字节码,并将二进制字节码解释编译为对应平台上的机器指令并交给底层硬件执行,对于每条Java指令JVM都有详细定义,Java程序与计算机的关系是用户的Java代码被编译为字节码文件、字节码文件被JVM装载解释成计算机指令交给操作系统,操作系统将机器指令交给计算机硬件执行
特点:一次编译,处处运行[所有的虚拟机实现都基于一次编译处处运行的原则];自动内存管理;自动垃圾回收机制[程序员编写Java代码不需要关注内存泄漏和内存溢出的风险,但是也会弱化Java程序员对内存管理、性能优化方面的能力]
HotSpot虚拟机是OpenJDK和OracleJDK共用的Java虚拟机
特点:
Java摒弃了C语言和C++需要动态分配内存和手动回收垃圾的特点,程序员自己分配内存和回收垃圾技术好会比较舒服,可以避免内存冗余,内存使用非常高效;技术不好内存管理就可能十分混乱
JVM规范有版本,会不停迭代,同时JVM规范是虚的,需要通过虚拟机来对规范进行实现,虚拟机不同厂商的落地实现也不同,Oracle不仅发布JVM规范,自己也实现了HotSpot虚拟机,HotSpot虚拟机的地位就像普通话一样
Java程序被编译成一份字节码文件,这个独一份的字节码文件可以在Win、Linux、Mac上的JVM分别解释运行,即字节码文件本身就具备跨平台的特性,字节码文件是Java虚拟机的执行基础,字节码文件可以通过不同语言编译获得,不一定只能通过Java语言编译获得,像Kotlin、Scala、JRuby、JavaScript等语言都可以通过提供各自的编译器并被编译成字节码文件在Java虚拟机上解释运行,只要这些字节码文件遵循Java虚拟机规范,这些字节码文件就能被虚拟机执行,因此Java虚拟机不要求源程序必须使用Java来写,因此JVM也是一种跨语言平台[Java7以后Java虚拟机通过JSR292规范实现在Java虚拟机上运行非Java语言编写的程序,并推出一系列项目和改进功能比如DaVinci Machine项目、Nashorn引擎、InvokeDynamic指令、java.lang.invoke包等等],只关心字节码文件,不是单纯地与Java语言终身绑定,只要其他编程语言的编译结果满足虚拟机的内部指令集、符号表和其他辅助信息[JVM出于安全考虑,对字节码文件有一些结构性约束和强类型语法规范]**,那么其就是一个能被虚拟机识别、装载和运行的有效字节码文件,时至今日Java虚拟机是比Java语言更成功、更优秀和更伟大的产品,因此字节码称为Java字节码不太规范,应该称为JVM字节码
因为JVM是跨语言平台,在Java平台上的多语言混合编程成为趋势,特别是在大型平台中混合使用不同语言解决特定领域的问题,比如使用Clojure解决并行处理问题,展示层使用JRuby/Rails,中间层使用Java,同时还可以实现不同语言间的交互[比如在Java中调用C],只需要在编译的时候按照各自的编译规则转换为字节码即可,能够实现Java调用C就像调用Java自己的API一样方便,不同语言最终编译后的字节码都运行在同一台虚拟机上,最终目的是推动JVM从Java虚拟机向多语言虚拟机的方向发展
源码编译成字节码文件需要编译器、字节码文件被JVM解释运行还需要解释器和JIT即时编译器
JVM结构图
[粗糙版]
1️⃣:字节码文件使用类加载器[也可以叫类装载器]加载到JVM内存的方法区中生成一个最大的Class对象并对静态属性做初始化,该过程涉及到加载、链接、初始化三个步骤,在运行时数据区中的方法区生成对应的Class实例
2️⃣:运行时数据区包含方法区、Java栈、本地方法栈、堆、程序计数器,其中方法区和堆是多线程共享的,Java栈、本地方法栈和程序计数器都是每个线程独一份,运行时数据区对应的类是Runtime类,这个类也是被设计成单例的
3️⃣:执行引擎,执行引擎分为解释器、JIT即时编译器、垃圾回收器;
解释器解释运行字节码,对所有代码都解释运行效率不高,因此使用JIT即时编译器将反复执行的热点代码专门编译成机器指令并缓存在方法区方便解释器解释运行的时候直接调用;解释器解释执行字节码涉及到去虚拟机栈的局部变量表中取数据和操作数栈,过程中需要创建对象需要使用堆空间,指令依次执行需要使用程序计数器,需要调用本地类库需要使用本地方法栈
垃圾回收器负责收回程序运行期间使用完毕的内存
操作系统只能识别机器指令、执行引擎充当的就是将高级语言翻译成机器指令的翻译者,高级语言被翻译成汇编语言,汇编语言被翻译成机器指令,机器指令交给CPU执行
主流的虚拟机都采用解释器解释执行字节码和JIT即时编译并存的方式

[细节版]
1️⃣:类加载子系统[Class Loader SubSystem]:字节码文件被加载依次经过加载、链接和初始化三个环节
加载:使用类加载器将字节码文件加载到内存中,典型的几类类加载器为引导类加载器BootStrap ClassLoader、扩展类加载器Extension ClassLoader、应用/系统类加载器Application ClassLoader;此外用户还可以自定义类加载器
链接:链接分为验证、准备、解析三个环节
初始化:主要涉及静态变量的显式初始化
2️⃣:运行时数据区[Runtime Data Areas]:运行时数据区包含的结构依次为
PC寄存器/程序计数器区域[PC Registers]:每个线程一份程序计数器
虚拟机栈区域[Stack Area]:每个线程一份虚拟机栈,虚拟机栈中的基本单元是栈帧[栈帧的内部结构分为LV局部/本地变量表、OS操作数栈、DL动态链接、RA方法返回地址]
本地方法栈[Native Method Stack]:本地C类库的方法调用执行的栈
堆区[Heap Area]:Java中创建的对象主体都分配在堆区,也是JVM中内存最大的一块空间,也是GC重点考虑的一块空间,同时堆区也是多线程共享的资源
方法区[Method Area]:方法区主要存放类信息、常量、域信息、方法信息;方法区是HotSpot虚拟机独有;JDK7以前方法区的落地实现叫永久代,JDK7以后叫元空间[永久代和元空间都是方法区的落地实现]
3️⃣:执行引擎[Execution Engine]:执行引擎包含解释器、JIT即时编译器、垃圾回收器,负责将字节码指令翻译成机器指令供CPU执行

JVM的生命周期
启动
JVM的启动通过引导类加载器创建一个初始类来完成,类和类名由具体的JVM实现自行确定;JVM要加载的类很多,涉及到的类加载器也很多,这个最初的类不是用户自定义的类也不是核心API对应的类
用户自定义的类通过系统类加载器[应用类加载器]加载到内存中,核心API相关的类需要被引导类加载器加载,父类的加载要先于子类的加载,加载了自定义的类和对应父类后,根据main方法相继加载需要使用到的类
执行
JVM唯一的任务就是执行Java程序,启动后就开始执行Java程序,程序运行结束JVM就自动结束,Java程序实际上是正在执行的Java虚拟机进程,使用jps命令能查看当前计算机上执行的所有Java进程,进程名为启动类的类名
退出
程序正常执行结束会导致虚拟机的退出
程序执行过程中遇到异常或者错误异常终止会导致虚拟机退出
操作系统错误会导致JVM进程终止
通过Java程序某个线程调用Runtime类或者System类的exit方法或者Runtime的halt方法,且Java安全管理器也允许此次exit或者halt操作,JVM进程也会终止
JNI[Java Native Interface]本地方法接口规范中的JNI Invocation API加载或者卸载JVM时,JVM也会先退出
Java系统结构图
最外层的JDK相较于JRE增加了javac将Java源程序编译成Java字节码文件,JConsole即JVM性能监控组件、javadoc文档组件、JMC内存泄漏检测组件等
JRE中包含完整Java SE的API

安卓Android的系统结构图
Google的安卓基于Linux内核即Linux Kernel,在该基础上提供Libraries包括数据库在内的各种库,在库的基础上提供应用框架Application framework,在框架上提供具体的应用程序applications,Android Runtime即安卓在5.0以前都用的Dalvik虚拟机[该虚拟机和JVM没什么关系,但是安卓使用Java语言作为开发语言,Java语言被编译为.dex文件]

JVM指令集架构模型
指令集架构有基于栈的指令集架构和基于寄存器的指令集架构两种,Java采用的是基于栈的指令集架构,寄存器只有一个PC Register程序计数器
基于栈的指令集架构
每执行一个方法就对方法做一次栈帧的入栈操作,方法执行完以后做一次栈帧的出栈操作
执行一个指令需要直到指令的地址和指令操作的数据即操作数,一地址指令指指令地址有一个,二地址指令指指令地址有两个,零地址指令指没有指令地址只有操作数;基于栈的指令集架构一般都是零地址指令,因为栈只会操作当前栈顶的数据,因此栈顶数据本身就是指令
基于栈的字节码指令以每八位单字节的方式对齐[单条指令一个字节],基于寄存器的指令以十六位双字节的方式对齐;但是由于指令要进行出栈入栈操作,虽然基于栈的单个指令字节数更小,但是总的指令数量更多,主要就是多在出栈入栈指令上
[基于栈的指令示例]
xxxxxxxxxxiconst_2 //常量2入栈istore_1 iconst_3 //常量3入栈istore_2 iload_1iload_2iadd //常量2、3出栈,执行相加操作istore_0 //结果5入栈[基于寄存器的指令示例]
xxxxxxxxxxmov eax,2 //将eax寄存器的值设为2add eax,3 //将eax寄存器的值加3特点[跨平台、指令集小、编译器容易实现,缺点是性能不高,同样的功能需要更多的指令]
设计和实现更简单,适用于像嵌入式这种资源受限的系统
使用零地址指令方式避开寄存器的指令分配问题
指令流中的指令大部分是零地址指令,执行过程依赖于操作栈,指令集更小,相应的编译器也更容易实现;但是相比于寄存器涉及到出栈入栈指令指令数量比较多
栈不需要硬件支持,可移植型和跨平台性更容易实现
基于寄存器的指令集架构
x86的二进制指令集和传统PC以及安卓的Davlik虚拟机都是基于寄存器的指令集架构
特点
指令由CPU的高速缓冲区中执行;执行速度比栈快,性能优秀,执行更高效
但是也是因为指令集的指令完全依赖于CPU中的寄存器,因此与硬件的耦合度比较高,不同平台的CPU架构不同,因此可移植性差;安卓因为不需要跨平台,又想要性能就选择了基于寄存器的指令集架构
完成一项操作相较于栈的指令数量更少,单条指令占用两个字节
基于寄存器的指令集架构一般都是以一地址指令、二地址指令、三地址指令为主
JVM发展历程
Classic VM:1996年由Sun公司随着JDK1.0发布,世界上第一款商用JVM,JDK1.4时被淘汰,现在的openJDK或者oracleJDK使用的HotSpot VM内置了Classic VM
该虚拟机的执行引擎没有提供JIT即时编译器,解释器和JIT即时编译器都能独立解释运行字节码文件,没有JIT即时编译器只是JVM需要逐行解释运行,向循环中的循环体也需要每次都解释执行,效率比较低下;JIT即时编译器一旦确定有被反复执行的热点代码,就会将热点代码编译成本地机器指令缓存到方法区的CodeCache中,后续调用相同的代码会直接使用缓存而不会再去逐行翻译
Classic VM可以外挂JIT即时编译器,但是一旦外挂使用了JIT即时编译器,就无法使用解释器,即在Classic VM中解释器和JIT即时编译器无法协同工作,此时JIT即时编译器会把所有代码都编译缓存起来,这种缓存比解释器现场编译字节码来的快,但是这样会导致JVM启动时会编译大量代码,导致JVM启动后很长一段时间都在执行编译工作造成JVM长时间卡顿;因此JIT即时编译器也不是优化做的越多越好,做的太多系统启动的时候就会等待很长时间,JIT即时编译器经过多年顶尖的工程师优化和解释器配合使用致使今天Java的运行性能已经不亚于C和C++
Exact VM:JDK1.2由SUN公司发布
提供准确式内存管理,虚拟机可以知道指定内存中的数据类型,不知道数据类型会带来一些麻烦,比如标记整理算法让JVM中的内存更紧凑会移动对象的位置,如果不知道内存存放的是数据本身还是引用会带来一些麻烦,在Classic VM中没有这种内存管理功能,Classic VM通过句柄额外记录对象的内存地址来查找对象,带来额外的开销
实现了解释器和即时编译器的混合工作,即时编译器还可以实时探测哪些代码是热点高频代码
Exact VM只在Sun公司自己的Solaris平台短暂使用,其他平台还是使用的Classic VM,还没投入其他平台就被HotSpot替换
HotSpot VM:HotSpot最初不是SUN公司的产品,是一家名为Longview Technologies的小公司设计,97年被SUN公司收购,JDK1.3成为默认虚拟机一直到现在
HotSpot虚拟机占有绝对的市场地位,不管是openJDK还是oracleJDK都用的是HotSpot虚拟机,这里主要也只介绍HotSpot虚拟机,不同的虚拟机实现使用的机制是不同的;openJDK开源的同时也带着将HotSpot VM开源了
方法区也是针对HotSpot独有的,像J9、JRockit都没有方法区[永久代只是针对HotSpot来说的,JDK8时HotSpot引入了元空间,元空间和JRockit一样使用本地空间]
从服务端、桌面、移动端到嵌入式都有HotSpot的身影
HotSpot的名字就指的是它的热点代码探测技术,通过程序计数器找到最具编译价值的代码,编译成机器指令缓存起来,需要时直接拷贝到栈上,同时即时编译器和解释器还可以协同工作,在最优程序响应时间和最佳执行性能之间自动平衡
JRockit VM:由BEA公司发布,后被Oracle收购
JRockit VM专注于服务器端应用,服务端应用最大的特点就是不太关注程序启动速度,比较关注程序的响应时间,因此JRockit VM内部没有解释器,全部代码都靠即时编译器编译;大量行业基准测试显示JRockit虚拟机是世界上最快的JVM,没有之一;使用JRockit VM一些性能甚至能提升超过70%,硬件成本减少高达50%
JRockit的JRockit Real Time面向延迟敏感型应用的解决方案提供毫秒、微秒级的JVM响应时间,适合财务、军事指挥、电信网络等场景
JRockit的Mission Control组件可以以极低的开销监控、管理和分析生产环境的应用程序,这个套件比较有用,被Oracle于JDK8整合到HotSpot中形成了现在的JDK Mission Control即JMC,具体分成了三个独立的应用程序,包含内存泄漏的检测器、JVM的运行时分析器和管理控制台,JMC的主要功能就是监控应用程序的内存泄漏;但是HotSpot和JRockit的架构差别比较大,整合的比较有限,整合过程中JRockit团队占主导,Java之父高斯林从oracle离职跳槽Google,研究人工智能和水下机器人去了
J9 VM[IBM Technology for Java Virtual Machine]:由IBM发布,简称IT4J,内部代号J9
市场定位和HotSpot接近,作为服务端、桌面、嵌入式等多用途VM,广泛用于IBM的各种Java产品
在IBM自家产品上测试速度世界最快,但是通用性和其他产品上的性能比不上JRockit,而且在windows场景下使用Bug很多
在可预见的将来J9不会出售,因为一方面J9比较适合IBM自家的产品,在其他平台上表现一般;此外如果J9被出售,一旦迭代J9就可能不再针对其自己产品进行优化,上层产品的质量就得不到保证;除非IBM被整体打包出售
2017年,IBM发布开源J9 VM,命名为OpenJ9,交给Eclipse基金会管理
CDC/CLDC HotSpot Implementation VM:oracle在Java ME方向发布的两款虚拟机
诺基亚时代,手机排行榜七八个都是诺基亚的,当时用的塞班系统,游戏和应用程序都是用Java开发的,使用的就是Java ME产品线,现在手机被Android和IOS二分天下,Java ME几乎已经失去移动端市场
KVM是CLDC-HI的早期产品,KVM因为简单、轻量和高度可移植型在更低端设备比如智能控制器、传感器和老年机上还使用的是KVM
Azul VM和BEA Liquid:这两款虚拟机比前面三大通用平台高性能JVM性能还要高,原因就是这两款虚拟机都与特定的硬件平台绑定,软硬件有极高的耦合度
Azul VM是Azul Systems公司基于HotSpot改进而来,运行该公司专有硬件Vega系统;每个Azul VM实例都可以管理数十个CPU和数百GB内存的硬件资源,提供在巨大内存空间内GC时间可控的垃圾回收器;并且实现专有硬件优化的线程调度功能
2010年,Azul Systems公司转向软件,发布了Zing JVM,在通用x86平台上提供接近于Vega系统的特性;号称在低延迟和快速预热场景方面表现比HotSpot还要好
BEA Liquid是BEA公司发布的,运行在BEA的Hypervisor系统上,Liquid VM可以理解成JRockit的一个虚拟化版本,不需要依赖操作系统就可以直接操控硬件实现一个专用操作系统的线程调度[无需切换内核态和用户态,能极致发挥硬件的性能]、文件系统、网络支持等功能;BEA Liquid随着JRockit的终止开发也停止开发了,归根结底应用场景有限
Apache Harmony:Apache发布,由IBM和Intel联合开发的开源JVM,开发主体是Intel
JCP组织对Java语言的更新具有绝对的话语权,SUN公司在JCP中的话语权很重,因为IBM老攻击SUN,导致SUN坚决不同意Harmony获得JCP认证,逼得IBM[干活的是Intel]最终放弃了Apache Harmony的更新,IBM转而参与OpenJDK,
Apache Harmony没有在Java领域大规模商用,但是在安卓的SDK中被大量使用,90%的代码使用Java语法,字节码结构和链接模型都不符合JCP规范,这是一款比较特别的虚拟机
Microsoft JVM:Java语言诞生之初是因为在浏览器中运行Java Applets小程序火起来的,微软为了在IE3浏览器中支持Java Applets,开发了Microsoft JVM
当时这款虚拟机是Windows平台下运行性能最好的,而且只能在windows平台下运行;1997年SUN以侵犯商标、不正当竞争指控微软,微软赔了很多钱;此后微软在WindowsXP SP3中移除了Microsoft JVM,因此现在Windows平台上需要安装HotSpot虚拟机
TaobaoJVM:由AliJVM团队基于HotSpot深度定制开源的服务器版JVM,简称AJDK,国内Java使用最全面彻底的公司就是阿里[技术层出不穷,要学习什么技术只需要关注国内大厂、美国大厂使用什么技术,绝对不会出问题,比如阿里在大数据方向已经全面倒向Flink]
创新性的GCIH[GC invisible heap]技术实现off-heap,将生命周期较长的Java对象从堆空间移到堆外,避免这些对象的垃圾回收管理,降低了GC的频率提高垃圾回收的效率;并且实现了GCIH中的对象在多个JVM进程中共享[正常来说进程间的数据一般是不共享的]
使用crc32指令降低对JNI的调用开销
提供针对大数据场景的ZenGC
Taobao JVM在阿里产品上性能高,但是在硬件产品上严重依赖Intel的CPU[凡是和硬件操作系统耦合深的JVM性能一般都比较好],损失兼容性,淘宝、天猫的产品全都把Oracle的JVM替换成了Taobao JVM[一般做的比较大都会基于自己的产品体系定制JVM保证产品的性能]
Dalvik VM:这款虚拟机只能称作虚拟机,不能称为Java虚拟机,没有遵循Java虚拟机规范,由谷歌发布应用于Android系统的虚拟机,在Android2.2提供了JIT即时编译器
Android5.0以前使用的Dalvik VM,此后替换为支持提前编译技术[AOT]的ART VM
提前编译指可以直接把源文件不经过字节码直接编译成机器指令,执行效率更高
因为没有遵循Java虚拟机规范,不能直接执行Java的字节码文件,执行dex[Dalvik Executable]格式的文件,dex格式文件可以通过Class文件转化得到,使用Java语法编写应用程序,可以直接使用大部分Java API
安卓应用程序都是以.apk结尾的文件,直接把后缀改成zip就可以解压,APP中有很多小图标,因此有很多细碎的小文件,解压会比较慢,解压就能看到APP的实际目录结构;里面就有很多.dex为后缀的文件,就相当于Java中的.class文件,dex文件是由字节码文件变形转化而来的,和Java有一定渊源;主要是GooGle考虑到C语言的执行效率高,但是开发效率低,同时也是SUN公司希望GooGle使用Java作为安卓的开发语言,也是这铸就了Java的地位,但是使用Java作为开发语言确不能在Java虚拟机上运行,后来还被oracle告了赔了88亿美元
采用基于寄存器的指令集架构,执行效率高,和硬件耦合度高
Graal VM:2018年oracle发布,号称Run Programs Faster Anywhere,在HotSpot基础上增强而成的跨语言全栈虚拟机,可以作为Java、Scala、Groovy、Kotlin、C、C++、JavaScript、Ruby、Python、R等任何语言的运行平台使用
支持不同语言中混用对方接口和对象,支持使用这些语言编写的本地库文件
原理是将语言的源代码编程成虚拟机能识别的类似字节码的中间语言格式,做到虚拟机与具体的机器特性相关而不是与具体的语言相关,只有Graal VM取代HotSpot的希望是最大的,没有意外oracle会将Graal VM作为重点发展的项目
除了以上虚拟机还有非常多针对具体应用场景的虚拟机
Java发展史上的重要事件
1990年,SUN公司的Green Team小组开发出新的程序语言命名为Oak,后改名为Java
1995年,SUN公司正式发布Java和HotJava产品,被认为是Java语言的元年
1996年,发布JDK1.0
1998年,发布JDK1.2,增加了JSP/Servlet、EJB规范,将Java分成J2EE、J2SE、J2ME,标志着Java向企业应用、桌面应用、移动设备应用三大领域挺进;最初Java被设计出来主要是瞄准嵌入式市场,但是现在随着发展,HotSpot虚拟机的宿主环境已经不再局限于嵌入式平台[嵌入式平台被认为是资源受限平台]
2000年,发布JDK1.3,正式发布Java HotSpot Virtual Machine,成为Java的默认虚拟机
2002年,发布JDK1.4,Java最初的虚拟机Classic虚拟机退出历史舞台、同年微软的.Net平台发布,技术实现和目标用户上都和Java很相似,现在二者也在竞争
2003年,以JVM作为跨语言平台的一些新语言Scala发布,同时一些其他语言Groovy也加入Java阵营
2004年,发布JDK1.5,同时改名为JavaSE 5.0,JDK1.5是里程碑的版本,很多新特性都是JDK1.5加入进来的[泛型、注解、反射、动态代理、并发编程],现在生产中最老的版本也就JDK1.5,不会再有之前的
2006年,发布JDK6,同年Java基于APL协议开源建立openJDK[此时有两个产品,一个是SUN公司的SUNJDK、一个是openJDK,最初二者除了版权注释外二者基本没有什么区别],HotSpot虚拟机也成为OpenJDK中的默认虚拟机
2007年,Clojure语言使用JVM作为解释运行的平台
2008年,Oracle收购BEA,从BEA得到Jrockit虚拟机[市面上有三大主流虚拟机HotSpot、JRockit、J9],注意此时Oracle和SUN公司没啥关系
2009年,Twitter将后台大部分程序从Ruby迁移到Scala
2010年,Oracle花了74亿美元收购SUN公司[因为美国发生金融危机],Oracle获得Java商标和HotSpot虚拟机,Oracle后续将JRockit和HotSpot做了整合HotRockit,随着JDK 8在2014年发布,二者整合难度很大,因为二者的架构存在明显的差异,现在的JDK 8的虚拟机仍然叫做HotSpot,实际上是二者整合后的版本
Oracle主要获取到Java的商标,Java语言本身的管理归到JCP组织,在JCP组织中Oracle的话语权比较重
2011年,JDK 7发布,在JDK 1.7u4中正式启用新的垃圾回收器G1
2017年,JDK 9发布,将G1设置为默认垃圾回收器替代并发垃圾回收器CMS,同年IBM的J9开源,形成现在的Open J9社区
2018年,Android的Java侵权案判决,Google赔偿Oracle88亿美元,好家伙白嫖,传言Oracle公司的法务人员比开发人员还多果然名不虚传
同年,Oracle将JDBC、JMS、Servlet捐赠给Eclipse基金会打理,JavaEE成为历史名称[因为Oracle要求不能使用Java的商标,要求商标完全归Oracle所有]
同年,JDK11发布[该版本是LTS版本],同时发布革命性的垃圾回收器ZGC[G1垃圾回收器目前还是比较主流,ZGC目前还是实验性,未来肯定会将G1换成ZGC,很多实验性数据证明性能已经远超G1]
同年调整JDK的授权许可,明确JDK每次发布都会发布OpenJDK和OracleJDK两个版本,OpenJDK基于OPL协议,OracleJDK基于OPN协议;OpenJDK的维护区间只有半年,如果存在Bug且距离发布时间超过半年只能通过安装更新的版本来解决,OracleJDK的维护区间为三年[OracleJDK商业使用需要付费,个人使用不需要付费],JDK11以前OracleJDK还会存在一些OpenJDK中没有的闭源功能,JDK11中可以认为OpenJDK和OracleJDK实质上是完全一样的
2019年,JDK12发布,OpenJDK加入了RedHat开发的Shenandoah GC,二者都处于实现阶段,ZGC的性能要表现得好一些,因为ZGC是Oracle自家产品,Oracle没有将Shenandoah GC加入到OracleJDK中,此时竟然出现了商用版本的功能竟然比开源版本的功能少的现象
类加载子系统
概念:
类加器子系统负责从文件系统或者网络中将一个或多个字节码文件以二进制流的方式加载到内存结构中初始化成一个或多个Class实例[元数据模板,通过元数据模板的构造器就能在堆空间中创建单个或多个对应类对象,通过Class对象的getClassLoader()方法可以获取负责该过程的类加载器对象,通过对象的getClass()方法能获取到对应的Class对象],除了该Class实例外,方法区中还会存放运行时常量池信息[需要用的常量池加载到内存中就称为运行时常量池],字符串字面值和数字常量
字节码文件在文件头有一个特定的模数标识cafebaby,该模数会参与链接阶段的验证
类加载器只负责字节码文件的加载,字节码文件是否可以被执行是由执行引擎决定的
类加载包含加载、链接和初始化三个环节
类加载过程
当前类HelloLoader是否装载,已装载直接进入链接流程
没有装载使用类加载器进行装载[自定义类使用应用类加载器装载],如果字节码文件不是一个合法的字节码文件,类加载器加载的过程中会抛出异常,加载成功再内存中生成元数据模板即对应Class实例
有了Class对象执行链接步骤
初始化对象实例

加载环节
概念:
通过一个类的全限定类名从物理磁盘或者网络获取该类的二进制字节流
将字节流代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表该类的java.lang.Class对象,该对象作为方法区中该类的各种数据访问入口
被加载的字节码文件来源
本地文件系统
从网络中获取,典型应用就是Web Applet
从zip压缩包中读取,这也是jar、war压缩格式的读取基础[jar包war包解压后都是字节码文件]
运行时通过计算生成,典型应用就是动态代理技术java.lang.reflect.Proxy
由其他文件生成,典型应用就是JSP应用
从专有数据库中提取[比较少见]
从加密文件中解密获取,是一种防止字节码文件被反编译的保护措施[比如将.apk格式替换成.zip格式解压就能获取字节码文件,对字节码文件进行反编译就能盗版一个软件或者寻找软件漏洞,因此一般都会对字节码文件进行加密防止我们这种人反编译字节码,真正运行的时候会自动对加密后的字节码文件进行一个解密操作]